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
+2046
-66
No files matched your search
@@ -225,7 +225,11 @@ all of them in every theme. When you add or change one:
|
|||||||
answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
|
answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
|
||||||
and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
|
and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
|
||||||
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
|
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
|
||||||
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`.
|
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`;
|
||||||
|
- Web Push's bookkeeping (see **Web Push**): `PushService.Reconcile` once the session is read (`Forget` when it isn't
|
||||||
|
signed in), `TurnOff` before `SignOut`, `Drop` for a persona deleted or refused, the app's badge cleared with the session,
|
||||||
|
and a notification's click: `?push={slot}` read as the app starts (its persona is acted as) and `PushOpened`, which
|
||||||
|
`main.js` calls when the app is open.
|
||||||
|
|
||||||
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
|
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
|
||||||
supplies:
|
supplies:
|
||||||
@@ -256,6 +260,10 @@ Never redeclare the base's members in a component.
|
|||||||
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||||
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
||||||
not carry a JWT: PrivaPub redirects them).
|
not carry a JWT: PrivaPub redirects them).
|
||||||
|
- `GetAs` / `PostAs` / `PutAs` / `DeleteAs` send a Mastodon API request as a given persona of the login, whichever the
|
||||||
|
session acts as: with that persona's own token (`TokenAuthStateProvider.GetPersonaToken(personaId)`), exchanged when
|
||||||
|
missing and kept without making the persona current (`SetPersonaToken`), never another persona's. Web Push uses them.
|
||||||
|
`TokenAuthStateProvider.PersonaTokenChanged` tells when a persona's token is replaced or forgotten.
|
||||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||||
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
`HttpClient`'s own is off.
|
`HttpClient`'s own is off.
|
||||||
@@ -273,7 +281,10 @@ Never redeclare the base's members in a component.
|
|||||||
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
||||||
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
||||||
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
|
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
|
||||||
forgets the session.
|
forgets the session. The exchange asks for `read write follow push` (`APIs.OAuth.Scopes`; PrivaPub grants `push` since
|
||||||
|
R9, and one from before R9 refuses the whole exchange, since its first-party client lacks the permission: PrivaPub's
|
||||||
|
R9 is deployed first); a token exchanged before that is refused a push subscription (403, "outside the authorized
|
||||||
|
scopes"), and `AuthService.RenewPersonaToken` revokes and forgets it, so the next request exchanges one with `push`.
|
||||||
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
|
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
|
||||||
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
|
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
|
||||||
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
|
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
|
||||||
@@ -322,13 +333,16 @@ Never redeclare the base's members in a component.
|
|||||||
`/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
|
`/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
|
||||||
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
|
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
|
||||||
reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and
|
reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and
|
||||||
`/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
|
`/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `PushService` (this device's Web Push subscriptions, one per
|
||||||
|
persona with its own token; see **Web Push**), `GroupService`, `InstanceService` and
|
||||||
`ProvenanceService`.
|
`ProvenanceService`.
|
||||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
||||||
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||||
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
|
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
|
||||||
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
|
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
|
||||||
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
|
shows who takes part; this device's first Web Push subscription (`PublicAct.Push`, asked once for the device,
|
||||||
|
`PublicActionNotices.PushTold`) says what the browser's push service learns (see **Web Push**). `data-public-notice`
|
||||||
|
names which: `reaction`, `participation` or `push`. An event card shows the persona's participation (`privapub.event.participation`: pending,
|
||||||
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
|
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
|
||||||
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
||||||
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own
|
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own
|
||||||
@@ -474,7 +488,8 @@ Never redeclare the base's members in a component.
|
|||||||
(`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or
|
(`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or
|
||||||
hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST
|
hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST
|
||||||
/clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at
|
/clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at
|
||||||
`/api/v1/preferences`; see **Reading preferences**), its mutes,
|
`/api/v1/preferences`; see **Reading preferences**), this device's notifications (`PushEditor`, the "Notifications on
|
||||||
|
this device" section, `data-settings-push`; see **Web Push**), its mutes,
|
||||||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages
|
||||||
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
|
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
|
||||||
imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
@@ -649,6 +664,58 @@ Never redeclare the base's members in a component.
|
|||||||
notification is dismissed from its line (`data-notification-dismiss`,
|
notification is dismissed from its line (`data-notification-dismiss`,
|
||||||
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
|
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
|
||||||
one alone), and the page takes that item out (`Content.OnDismissed`).
|
one alone), and the page takes that item out (`Content.OnDismissed`).
|
||||||
|
- **Web Push** (PrivaPub's R9, Mastodon's `/api/v1/push/subscription`; `Services/PushService.cs`, `wwwroot/js/push.js`,
|
||||||
|
`wwwroot/push-worker.js`, `Components/Settings/PushEditor.razor`). Notifications reach the device while decePub is closed.
|
||||||
|
- **One subscription per persona, each in its own registration.** PrivaPub keeps one subscription per access token, so
|
||||||
|
per persona, and pushes it that persona's notifications alone. Each persona ticked on this device gets a service worker
|
||||||
|
registration of its own: `push-worker.js` at the scope `push/{slot}/`, the slot a random name this device keeps
|
||||||
|
(`PushDevice` in localStorage: the personas with their slots and PrivaPub's subscription ids, the alerts, the policy;
|
||||||
|
never a token). So each persona's subscription has its own endpoint and keys: PrivaPub stores nothing that ties two
|
||||||
|
personas of one login together (one endpoint shared by them would), and the worker knows its persona from its scope.
|
||||||
|
The app's own workers (`service-worker.js`, `service-worker.published.js`) receive no pushes; one `push-worker.js`
|
||||||
|
serves both builds and is never precached.
|
||||||
|
- **Each persona's own token.** Its subscription is made (`POST`), read (`GET`), changed (`PUT`, `data[alerts]` and
|
||||||
|
`data[policy]`) and deleted (`DELETE`) as that persona (`IHttpService`'s `*As`), whichever persona the session acts
|
||||||
|
as; JSON, which PrivaPub flattens to Mastodon's `subscription[endpoint]`, `subscription[keys][p256dh]`,
|
||||||
|
`subscription[keys][auth]`, `subscription[standard]` (true: RFC 8291's aes128gcm, unless the browser lists only
|
||||||
|
aesgcm), `data[alerts][…]`, `data[policy]`. The browser subscribes with the server's key, the instance API's
|
||||||
|
`configuration.vapid.public_key` (`InstanceV2.Configuration.Vapid`, read through `AboutService`).
|
||||||
|
- **Privacy.** The payload is encrypted to the persona's browser subscription alone, so the push service (Google's,
|
||||||
|
Mozilla's, Apple's…) learns only that something arrived, when and how big, never the words, the sender or the persona.
|
||||||
|
The payload is Mastodon's and carries the subscription's access token: `push-worker.js` never reads, keeps, shows or
|
||||||
|
passes it on; it reads `title`, `body`, `icon`, `notification_id` (the notification's `tag`, so a repeat replaces it),
|
||||||
|
`notification_type` and `preferred_locale`. Before this device's first subscription, the one-time notice says so
|
||||||
|
(`PublicAct.Push`).
|
||||||
|
- **Lifecycle.** A subscription ends with its token on PrivaPub, so `PushService` follows the tokens: a persona whose
|
||||||
|
token is replaced or forgotten (a 401 exchanged again, a password change, two-factor turned on, a scope renewal) is
|
||||||
|
subscribed again with the new one two seconds later (`PersonaTokenChanged`); every start of the session reconciles
|
||||||
|
(`Reconcile`: registrations it keeps no persona for are unregistered; permission withdrawn, or push gone from the
|
||||||
|
browser, unsubscribes everything; a persona deleted or suspended is dropped; each other one is checked with `GET` and
|
||||||
|
`PUT`/`POST` only when PrivaPub's differs, e.g. a new endpoint after the worker's `pushsubscriptionchange`). Signing out
|
||||||
|
`DELETE`s each persona's subscription with its own token, then unregisters (`TurnOff`, in `CascadingState.SignOut`);
|
||||||
|
"Sign out everywhere", deleting the login, and a session that ended on its own only unregister (`Forget`: PrivaPub
|
||||||
|
ended them with the tokens). A persona deleted (`CascadingState.PersonaDeleted`) or refused a token is `Drop`ped.
|
||||||
|
- **A click** closes the notification and opens decePub on that persona's Notifications tab: an open decePub tab is
|
||||||
|
focused and posted `{type: "decepub-push-open", slot}` (the worker can't navigate a tab another worker controls), which
|
||||||
|
`main.js` hands to `CascadingState.PushOpened` (it switches to the persona, then navigates to `?notifications=1`);
|
||||||
|
otherwise a new tab opens at `/?notifications=1&push={slot}` (CascadingState acts as the slot's persona when the session
|
||||||
|
is read; `Index.razor` opens the Notifications tab and takes the question out of the address with `replace`).
|
||||||
|
- **The badge.** `Index.razor` sets the installed app's badge to the persona's unread notifications whenever the count
|
||||||
|
changes, and clears it when they are read (`AppStatusService.SetAppBadge` / `ClearAppBadge`; `main.js` does nothing
|
||||||
|
where the Badging API is missing); a push sets a plain dot until the app counts; the session's end clears it.
|
||||||
|
- **Settings** (`PushEditor`, `data-push-editor`, `data-push-state` `subscribed|off|denied|unsupported`): on or off for
|
||||||
|
this device (`data-push-toggle="on|off"`: the notice, then the browser's permission asked from the click, then each
|
||||||
|
ticked persona subscribed; off `TurnOff`s); the personas of the login (`input[data-push-persona="{userName}"]`, a
|
||||||
|
suspended one disabled; the current one ticked while none is subscribed); the alerts in four groups (mentions and
|
||||||
|
posts: `mention`, `status`, `quote`, `update`, `poll`; reactions: `favourite`, `reblog`, `pleroma:emoji_reaction`;
|
||||||
|
follows: `follow`, `follow_request`; the staff: `moderation_warning`; each `input[data-push-alert="{alert}"]`; those
|
||||||
|
PrivaPub keeps but never fires, `quoted_update`, `severed_relationships`, `annual_report` and the admin ones, are not
|
||||||
|
offered and stay off); whose (`select[data-push-policy]`: all, followed, follower, none); a test notification shown by
|
||||||
|
the persona's registration alone, nothing sent to the server (`data-push-test`); `data-push-reset`; `data-push-save`
|
||||||
|
(while on: subscribes the personas newly ticked, unsubscribes the others, `PUT`s the alerts and policy). Unsupported
|
||||||
|
(`data-push-unsupported`: `server` without a VAPID key, `insecure`, `ios-install` for an iPhone or iPad whose decePub
|
||||||
|
isn't added to the Home Screen and opened from there, `unsupported`) says why; blocked notifications say how to allow
|
||||||
|
them (`data-push-denied`).
|
||||||
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||||
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||||
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
@@ -750,7 +817,7 @@ Never redeclare the base's members in a component.
|
|||||||
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||||
|
|
||||||
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`,
|
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`,
|
||||||
the server cache `ServerInfos` and the server's custom emoji `CustomEmojiCache`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
the server cache `ServerInfos`, the server's custom emoji `CustomEmojiCache` and this device's Web Push `PushDevice`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
||||||
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
|
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
|
||||||
still holds it.
|
still holds it.
|
||||||
|
|
||||||
@@ -764,10 +831,17 @@ it: the root's name, its policies as claims, and the current persona. Pages that
|
|||||||
- Each helper reports its errors back through `window.logFromJs`.
|
- Each helper reports its errors back through `window.logFromJs`.
|
||||||
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
|
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
|
||||||
`wwwroot/index.html`; the feed's
|
`wwwroot/index.html`; the feed's
|
||||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`, as is Web Push's (`js/push.js`,
|
||||||
|
by `PushService`: support and permission, a persona's registration and subscription made, read and dropped, the slots
|
||||||
|
held, a test notification). `main.js` listens for the personas' push workers (`navigator.serviceWorker` messages, a
|
||||||
|
notification's click while the app is open) and calls `CascadingState.PushOpened`.
|
||||||
|
|
||||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
|
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
|
||||||
published worker names its cache after the assets manifest's version, so each publish drops the previous cache.
|
published worker names its cache after the assets manifest's version, so each publish drops the previous cache. Web Push
|
||||||
|
has workers of its own, one registration per persona subscribed (`wwwroot/push-worker.js` at `push/{slot}/`, the same file
|
||||||
|
in both builds, kept out of the precache; see **Web Push**): `push` shows the notification, `notificationclick` opens
|
||||||
|
the app on that persona's notifications, `pushsubscriptionchange` subscribes again with the same key. They control no
|
||||||
|
page and have no fetch handler.
|
||||||
|
|
||||||
## Development rules
|
## Development rules
|
||||||
|
|
||||||
@@ -900,7 +974,7 @@ host. The steps:
|
|||||||
roll back to the snapshot.
|
roll back to the snapshot.
|
||||||
|
|
||||||
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
|
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
|
||||||
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost.
|
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost (the workers `service-worker.js` and `push-worker.js` are there).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ServerInfo))]
|
[JsonSerializable(typeof(ServerInfo))]
|
||||||
[JsonSerializable(typeof(List<ServerInfo>))]
|
[JsonSerializable(typeof(List<ServerInfo>))]
|
||||||
[JsonSerializable(typeof(CustomEmojiCache))]
|
[JsonSerializable(typeof(CustomEmojiCache))]
|
||||||
|
[JsonSerializable(typeof(PushDevice))]
|
||||||
[JsonSerializable(typeof(UploadMedia))]
|
[JsonSerializable(typeof(UploadMedia))]
|
||||||
[JsonSerializable(typeof(ValidationErrors))]
|
[JsonSerializable(typeof(ValidationErrors))]
|
||||||
[JsonSerializable(typeof(WebResult))]
|
[JsonSerializable(typeof(WebResult))]
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
|
@* The one-time notice before a persona's first reaction or poll vote, before it first joins an event, and before this
|
||||||
(PublicNoticeService), in MainLayout. *@
|
device's first Web Push subscription (PublicNoticeService), in MainLayout. data-public-notice names which. *@
|
||||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
||||||
data-public-notice>
|
data-public-notice="@ActName">
|
||||||
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
|
<Heading>@HeadingText</Heading>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<p class="text-sm">
|
<p class="text-sm">@NoticeText</p>
|
||||||
@(Notices.Act == PublicAct.Participation
|
|
||||||
? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
|
|
||||||
: Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""])
|
|
||||||
</p>
|
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
<Actions>
|
<Actions>
|
||||||
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
||||||
@@ -22,6 +18,27 @@
|
|||||||
@code {
|
@code {
|
||||||
[Inject] PublicNoticeService Notices { get; set; }
|
[Inject] PublicNoticeService Notices { get; set; }
|
||||||
|
|
||||||
|
string ActName => Notices.Act switch
|
||||||
|
{
|
||||||
|
PublicAct.Participation => "participation",
|
||||||
|
PublicAct.Push => "push",
|
||||||
|
_ => "reaction"
|
||||||
|
};
|
||||||
|
|
||||||
|
string HeadingText => Notices.Act switch
|
||||||
|
{
|
||||||
|
PublicAct.Participation => Localizer["Joining an event is public"],
|
||||||
|
PublicAct.Push => Localizer["Notifications go through your browser's push service"],
|
||||||
|
_ => Localizer["Reactions and votes are public"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string NoticeText => Notices.Act switch
|
||||||
|
{
|
||||||
|
PublicAct.Participation => Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""],
|
||||||
|
PublicAct.Push => Localizer["To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together."],
|
||||||
|
_ => Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
|
||||||
|
};
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
Notices.Changed += Refresh;
|
Notices.Changed += Refresh;
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
@code {
|
@code {
|
||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
[Inject] ConfirmService Confirm { get; set; }
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
[Inject] PushService Push { get; set; }
|
||||||
|
|
||||||
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
|
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
|
||||||
ViewAccount account;
|
ViewAccount account;
|
||||||
@@ -294,6 +295,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivaPub ended every Web Push subscription with the tokens: this browser forgets its own
|
||||||
|
await Push.Forget();
|
||||||
await CascadingState.SignOut();
|
await CascadingState.SignOut();
|
||||||
Navigation.NavigateTo("login");
|
Navigation.NavigateTo("login");
|
||||||
}
|
}
|
||||||
@@ -309,6 +312,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the personas' Web Push subscriptions went with them: this browser forgets its own, asking no persona gone
|
||||||
|
await Push.Forget();
|
||||||
await CascadingState.SignOut();
|
await CascadingState.SignOut();
|
||||||
Navigation.NavigateTo("login");
|
Navigation.NavigateTo("login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
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)
|
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
|
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>
|
<CascadingValue IsFixed=false Value=this>
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
@@ -27,6 +30,8 @@
|
|||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
|
[Inject] PushService PushService { get; set; }
|
||||||
|
[Inject] NavigationManager Navigation { get; set; }
|
||||||
[Inject] CoalescingStringLocalizer Localizer { get; set; }
|
[Inject] CoalescingStringLocalizer Localizer { get; set; }
|
||||||
|
|
||||||
public bool IsOnline { get; private set; } = true;
|
public bool IsOnline { get; private set; } = true;
|
||||||
@@ -66,10 +71,14 @@
|
|||||||
CancellationTokenSource pendingSettingsSave;
|
CancellationTokenSource pendingSettingsSave;
|
||||||
/// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary>
|
/// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary>
|
||||||
bool handlingRefusal;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var cancellationToken = disposeCancellation.Token;
|
var cancellationToken = disposeCancellation.Token;
|
||||||
|
pushSlot = QueryValue(Navigation.Uri, "push");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
cascadingStateReference = DotNetObjectReference.Create(this);
|
cascadingStateReference = DotNetObjectReference.Create(this);
|
||||||
@@ -209,6 +218,8 @@
|
|||||||
ClearSession();
|
ClearSession();
|
||||||
IsSessionLoaded = true;
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +232,9 @@
|
|||||||
foreach (var usable in Personas.Where(CanUse))
|
foreach (var usable in Personas.Where(CanUse))
|
||||||
AuthStateProvider.Unrefuse(usable.Id);
|
AuthStateProvider.Unrefuse(usable.Id);
|
||||||
var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId);
|
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))
|
if (stored is not null && !CanUse(stored))
|
||||||
Toast.AddWarning(personaId is null
|
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)]
|
? 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();
|
Preferences = new();
|
||||||
IsSessionLoaded = true;
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
ReconcilePush(cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,8 +258,79 @@
|
|||||||
await SwitchPersona(personaId, cancellationToken);
|
await SwitchPersona(personaId, cancellationToken);
|
||||||
IsSessionLoaded = true;
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
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>
|
/// <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;
|
public static bool CanUse(ViewAvatar persona) => persona.AccountState is not ViewAvatarState.Suspended;
|
||||||
|
|
||||||
@@ -285,10 +370,13 @@
|
|||||||
StateHasChanged();
|
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)
|
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Personas = await AuthService.Personas(cancellationToken);
|
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();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +387,8 @@
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task PersonaDeleted(string personaId, CancellationToken cancellationToken = default)
|
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);
|
await AuthStateProvider.ForgetPersonaToken(personaId, cancellationToken);
|
||||||
Personas = await AuthService.Personas(cancellationToken);
|
Personas = await AuthService.Personas(cancellationToken);
|
||||||
if (personaId != PersonaId)
|
if (personaId != PersonaId)
|
||||||
@@ -324,7 +414,12 @@
|
|||||||
StateHasChanged();
|
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>
|
/// <summary>
|
||||||
/// PrivaPub refused the current persona a token while the login's session is good: the personas are read again, and the
|
/// 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();
|
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)
|
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
CancelSettingsSave();
|
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);
|
await AuthService.Logout(cancellationToken);
|
||||||
ClearSession();
|
ClearSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -419,6 +518,8 @@
|
|||||||
void ClearSession()
|
void ClearSession()
|
||||||
{
|
{
|
||||||
CancelSettingsSave();
|
CancelSettingsSave();
|
||||||
|
// the app's badge counted that session's notifications
|
||||||
|
Status.ClearAppBadge();
|
||||||
loginSettings = null;
|
loginSettings = null;
|
||||||
IsSignedIn = false;
|
IsSignedIn = false;
|
||||||
RootUserName = null;
|
RootUserName = null;
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(AnnouncementReactionEvent))]
|
[JsonSerializable(typeof(AnnouncementReactionEvent))]
|
||||||
[JsonSerializable(typeof(CustomEmoji))]
|
[JsonSerializable(typeof(CustomEmoji))]
|
||||||
[JsonSerializable(typeof(List<CustomEmoji>))]
|
[JsonSerializable(typeof(List<CustomEmoji>))]
|
||||||
|
[JsonSerializable(typeof(WebPushSubscription))]
|
||||||
|
[JsonSerializable(typeof(PushSubscriptionForm))]
|
||||||
|
[JsonSerializable(typeof(PushDataForm))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+7
-1
@@ -122,7 +122,8 @@ public static class APIs
|
|||||||
public const string POST_Revoke = "oauth/revoke";
|
public const string POST_Revoke = "oauth/revoke";
|
||||||
public const string TokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange";
|
public const string TokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange";
|
||||||
public const string JwtTokenType = "urn:ietf:params:oauth:token-type:jwt";
|
public const string JwtTokenType = "urn:ietf:params:oauth:token-type:jwt";
|
||||||
public const string Scopes = "read write follow";
|
// push since R9: a persona's token subscribes that persona to Web Push (PrivaPub's PersonaExchange grants it)
|
||||||
|
public const string Scopes = "read write follow push";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Mastodon
|
public static class Mastodon
|
||||||
@@ -196,6 +197,11 @@ public static class APIs
|
|||||||
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
||||||
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
||||||
public const string POST_Reports = "api/v1/reports";
|
public const string POST_Reports = "api/v1/reports";
|
||||||
|
// Web Push: one subscription per access token, so the persona whose token goes with the request
|
||||||
|
public const string GET_PushSubscription = "api/v1/push/subscription";
|
||||||
|
public const string POST_PushSubscription = "api/v1/push/subscription";
|
||||||
|
public const string PUT_PushSubscription = "api/v1/push/subscription";
|
||||||
|
public const string DELETE_PushSubscription = "api/v1/push/subscription";
|
||||||
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
|
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
|
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
public const string GET_NotificationRequests = "api/v1/notifications/requests";
|
public const string GET_NotificationRequests = "api/v1/notifications/requests";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace decePubClient.Models.Mastodon;
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
|
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
|
||||||
/// its rules, and what an upload may be.</summary>
|
/// its rules, what an upload may be, and its Web Push key.</summary>
|
||||||
public class InstanceV2
|
public class InstanceV2
|
||||||
{
|
{
|
||||||
public string Domain { get; set; }
|
public string Domain { get; set; }
|
||||||
@@ -38,6 +38,14 @@ public class InstanceConfiguration
|
|||||||
{
|
{
|
||||||
public MediaConfiguration MediaAttachments { get; set; } = new();
|
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||||
public AccountsConfiguration Accounts { get; set; } = new();
|
public AccountsConfiguration Accounts { get; set; } = new();
|
||||||
|
public VapidConfiguration Vapid { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's VAPID public key (RFC 8292), the applicationServerKey a browser subscribes to Web Push with: the
|
||||||
|
/// uncompressed P-256 point, URL-safe base64. Null on a server that pushes nothing.</summary>
|
||||||
|
public class VapidConfiguration
|
||||||
|
{
|
||||||
|
public string PublicKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What an account may hold: how many hashtags it features on its profile (PrivaPub's 10).</summary>
|
/// <summary>What an account may hold: how many hashtags it features on its profile (PrivaPub's 10).</summary>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>Mastodon's WebPushSubscription (/api/v1/push/subscription): one per access token, so one per persona.
|
||||||
|
/// Alerts holds every alert PrivaPub knows, true or false.</summary>
|
||||||
|
public class WebPushSubscription
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Endpoint { get; set; }
|
||||||
|
public bool Standard { get; set; }
|
||||||
|
public Dictionary<string, bool> Alerts { get; set; } = [];
|
||||||
|
/// <summary>The server's VAPID public key, URL-safe base64.</summary>
|
||||||
|
public string ServerKey { get; set; }
|
||||||
|
/// <summary>all, followed, follower or none.</summary>
|
||||||
|
public string Policy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/v1/push/subscription as JSON: the browser's subscription and what it is pushed. PrivaPub reads it as
|
||||||
|
/// Mastodon's flattened parameters (subscription[endpoint], subscription[keys][p256dh], data[alerts][mention], ...).</summary>
|
||||||
|
public class PushSubscriptionForm
|
||||||
|
{
|
||||||
|
public PushEndpoint Subscription { get; set; } = new();
|
||||||
|
public PushData Data { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The browser's push subscription: where the push service takes the pushes, and the keys they are encrypted
|
||||||
|
/// to. Standard: RFC 8291's aes128gcm (with RFC 8292's VAPID header), which every browser that pushes decrypts.</summary>
|
||||||
|
public class PushEndpoint
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; }
|
||||||
|
public PushKeys Keys { get; set; } = new();
|
||||||
|
public bool Standard { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The browser's P-256 public key and authentication secret, URL-safe base64.</summary>
|
||||||
|
public class PushKeys
|
||||||
|
{
|
||||||
|
[JsonPropertyName("p256dh")]
|
||||||
|
public string P256dh { get; set; }
|
||||||
|
public string Auth { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a subscription is pushed: each alert on or off (one left out is off), and whose.</summary>
|
||||||
|
public class PushData
|
||||||
|
{
|
||||||
|
public Dictionary<string, bool> Alerts { get; set; } = [];
|
||||||
|
public string Policy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT /api/v1/push/subscription: data[alerts] and data[policy] replace what the subscription had.</summary>
|
||||||
|
public class PushDataForm
|
||||||
|
{
|
||||||
|
public PushData Data { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ namespace decePubClient.Models;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
|
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
|
||||||
/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name.
|
/// once, before its first one): reactions and poll votes, and joining an event; and whether this device was told, before
|
||||||
|
/// its first Web Push subscription, what the browser's push service learns. Kept in localStorage under its type name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PublicActionNotices
|
public class PublicActionNotices
|
||||||
{
|
{
|
||||||
@@ -10,10 +11,14 @@ public class PublicActionNotices
|
|||||||
public List<string> PersonaIds { get; set; } = [];
|
public List<string> PersonaIds { get; set; } = [];
|
||||||
/// <summary>Told about joining events.</summary>
|
/// <summary>Told about joining events.</summary>
|
||||||
public List<string> EventPersonaIds { get; set; } = [];
|
public List<string> EventPersonaIds { get; set; } = [];
|
||||||
|
/// <summary>This device was told about Web Push (once for the device, whichever personas it subscribes).</summary>
|
||||||
|
public bool PushTold { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum PublicAct
|
public enum PublicAct
|
||||||
{
|
{
|
||||||
Reaction = 0,
|
Reaction = 0,
|
||||||
Participation = 1
|
Participation = 1,
|
||||||
|
/// <summary>Notifications on this device go through the browser's push service, which learns when they arrive.</summary>
|
||||||
|
Push = 2
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
namespace decePubClient.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Web Push on this device (Services/PushService.cs), kept in localStorage under its type name: the personas whose
|
||||||
|
/// notifications reach it, and the alerts and policy each of their subscriptions holds. Never a token: each persona's
|
||||||
|
/// subscription is made with that persona's own token, which only AuthData keeps.
|
||||||
|
/// </summary>
|
||||||
|
public class PushDevice
|
||||||
|
{
|
||||||
|
/// <summary>The personas subscribed on this device, each through its own service worker registration.</summary>
|
||||||
|
public List<PushPersona> Personas { get; set; } = [];
|
||||||
|
/// <summary>The alerts turned on (PushAlerts.Offered's names).</summary>
|
||||||
|
public List<string> Alerts { get; set; } = [.. PushAlerts.Offered];
|
||||||
|
public PushPolicy Policy { get; set; } = PushPolicy.All;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One persona's subscription on this device.</summary>
|
||||||
|
public class PushPersona
|
||||||
|
{
|
||||||
|
/// <summary>The persona's avatar id.</summary>
|
||||||
|
public string PersonaId { get; set; }
|
||||||
|
/// <summary>A random name this device gives the persona's service worker registration (its scope is push/{slot}/), so
|
||||||
|
/// the persona's subscription has an endpoint and keys of its own; it means nothing anywhere else.</summary>
|
||||||
|
public string Slot { get; set; }
|
||||||
|
/// <summary>PrivaPub's id for the subscription, as it last answered.</summary>
|
||||||
|
public string SubscriptionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What js/push.js says of this browser: whether it can be pushed to, why not (insecure, unsupported, or
|
||||||
|
/// ios-install: an iPhone or iPad pushes only to the app added to the Home Screen), and what the reader allowed.</summary>
|
||||||
|
public class PushSupport
|
||||||
|
{
|
||||||
|
public bool Supported { get; set; }
|
||||||
|
public string Reason { get; set; }
|
||||||
|
/// <summary>default, granted or denied (Notification.permission).</summary>
|
||||||
|
public string Permission { get; set; }
|
||||||
|
|
||||||
|
public bool IsGranted => Permission == "granted";
|
||||||
|
public bool IsDenied => Permission == "denied";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A persona's push subscription in this browser, as js/push.js reads it (its keys URL-safe base64), or why it
|
||||||
|
/// couldn't be made.</summary>
|
||||||
|
public class BrowserSubscription
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; }
|
||||||
|
public string P256dh { get; set; }
|
||||||
|
public string Auth { get; set; }
|
||||||
|
public bool Standard { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Whose notifications a Web Push subscription is pushed (Mastodon's data[policy]): anyone's, those of the
|
||||||
|
/// accounts the persona follows, of its followers, or nobody's. What the persona tells itself (its poll ended, a warning
|
||||||
|
/// from the staff) passes every policy but None.</summary>
|
||||||
|
public enum PushPolicy
|
||||||
|
{
|
||||||
|
All = 0,
|
||||||
|
Followed = 1,
|
||||||
|
Follower = 2,
|
||||||
|
None = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PushPolicies
|
||||||
|
{
|
||||||
|
/// <summary>The policy as Mastodon's API names it.</summary>
|
||||||
|
public static string Name(PushPolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
PushPolicy.Followed => "followed",
|
||||||
|
PushPolicy.Follower => "follower",
|
||||||
|
PushPolicy.None => "none",
|
||||||
|
_ => "all"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A policy Mastodon's API named; all for anything else, as PrivaPub reads a policy left out.</summary>
|
||||||
|
public static PushPolicy Of(string name) => name switch
|
||||||
|
{
|
||||||
|
"followed" => PushPolicy.Followed,
|
||||||
|
"follower" => PushPolicy.Follower,
|
||||||
|
"none" => PushPolicy.None,
|
||||||
|
_ => PushPolicy.All
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The alerts a decePub subscription offers: Mastodon 4.5's names (data[alerts]) that PrivaPub notifies, then Pleroma's
|
||||||
|
/// emoji reactions. PrivaPub keeps admin.sign_up, admin.report, quoted_update, severed_relationships and annual_report
|
||||||
|
/// too, but never fires them, so they are never offered and always sent off.
|
||||||
|
/// </summary>
|
||||||
|
public static class PushAlerts
|
||||||
|
{
|
||||||
|
public static readonly string[] Offered =
|
||||||
|
[
|
||||||
|
"mention", "status", "quote", "update", "poll", "favourite", "reblog", "pleroma:emoji_reaction", "follow", "follow_request",
|
||||||
|
"moderation_warning"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-4
@@ -11,7 +11,8 @@
|
|||||||
PrivaPub's streaming API (StreamingService) for the feeds it streams (the direct conversations too, on the direct
|
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
|
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'
|
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">
|
<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 *@
|
@* 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] FeedMemory Memory { get; set; }
|
||||||
[Inject] AccountService AccountService { 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();
|
FeedFilter Filter { get; set; } = new();
|
||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
List<FeedPost> pending = [];
|
List<FeedPost> pending = [];
|
||||||
List<ViewGroup> groups = [];
|
List<ViewGroup> groups = [];
|
||||||
List<MastodonList> lists = [];
|
List<MastodonList> lists = [];
|
||||||
int unreadNotifications;
|
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
|
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
||||||
int filteredRequests;
|
int filteredRequests;
|
||||||
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
|
// 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() };
|
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
||||||
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
||||||
Filter.TimelineType = TimelineType.Home;
|
Filter.TimelineType = TimelineType.Home;
|
||||||
|
if (TakeFromPush())
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
|
||||||
|
|
||||||
if (CascadingState.IsOffline)
|
if (CascadingState.IsOffline)
|
||||||
{
|
{
|
||||||
@@ -256,6 +267,16 @@
|
|||||||
// page belongs to it.
|
// page belongs to it.
|
||||||
protected override async Task OnParametersSetAsync()
|
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)
|
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
|
||||||
{
|
{
|
||||||
waitingForPersona = false;
|
waitingForPersona = false;
|
||||||
@@ -283,6 +304,39 @@
|
|||||||
_ = PollNewer(pageCancellation.Token);
|
_ = 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
|
/// <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>
|
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
|
||||||
FilterContext? FilterIn => Filter.TimelineType switch
|
FilterContext? FilterIn => Filter.TimelineType switch
|
||||||
@@ -454,13 +508,13 @@
|
|||||||
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
|
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
|
||||||
if (Filter.TimelineType is not TimelineType.Notifications)
|
if (Filter.TimelineType is not TimelineType.Notifications)
|
||||||
{
|
{
|
||||||
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
|
ShowUnread(await TimelineService.UnreadNotifications(cancellationToken));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
|
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
|
||||||
if (newest is not null)
|
if (newest is not null)
|
||||||
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
||||||
unreadNotifications = 0;
|
ShowUnread(0);
|
||||||
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
||||||
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
|
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
|
||||||
}
|
}
|
||||||
@@ -522,7 +576,7 @@
|
|||||||
pending.Insert(0, group);
|
pending.Insert(0, group);
|
||||||
break;
|
break;
|
||||||
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
|
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
|
||||||
unreadNotifications++;
|
ShowUnread(unreadNotifications + 1);
|
||||||
break;
|
break;
|
||||||
case "delete" when message.DeletedId is { } id:
|
case "delete" when message.DeletedId is { } id:
|
||||||
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
||||||
|
|||||||
@@ -194,6 +194,27 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</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">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<DiscoveryService>()
|
.AddScoped<DiscoveryService>()
|
||||||
.AddScoped<StreamingService>()
|
.AddScoped<StreamingService>()
|
||||||
.AddScoped<PublicNoticeService>()
|
.AddScoped<PublicNoticeService>()
|
||||||
|
.AddScoped<PushService>()
|
||||||
.AddScoped<ConfirmService>()
|
.AddScoped<ConfirmService>()
|
||||||
.AddScoped<OverlayService>()
|
.AddScoped<OverlayService>()
|
||||||
.AddScoped<MediaService>()
|
.AddScoped<MediaService>()
|
||||||
|
|||||||
@@ -3809,4 +3809,106 @@
|
|||||||
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
|
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
|
||||||
<value>{0} post con questo hashtag, l'ultimo il {1}</value>
|
<value>{0} post con questo hashtag, l'ultimo il {1}</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="This server does not offer push notifications." xml:space="preserve">
|
||||||
|
<value>Questo server non offre notifiche push.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not subscribe: {0}" xml:space="preserve">
|
||||||
|
<value>Questo browser non si è iscritto: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications go through your browser's push service" xml:space="preserve">
|
||||||
|
<value>Le notifiche passano dal servizio push del tuo browser</value>
|
||||||
|
</data>
|
||||||
|
<data name="To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together." xml:space="preserve">
|
||||||
|
<value>Per raggiungere questo dispositivo mentre decePub è chiuso, PrivaPub affida ogni notifica al servizio push che usa il tuo browser (quello di Google, di Mozilla, di Apple…). È cifrata per questo solo browser: il servizio push sa quando arriva una notifica e quanto è grande, mai cosa dice, chi l'ha mandata né per quale persona è. Ogni persona ha una propria iscrizione, così PrivaPub non conserva nulla che leghi tra loro le tue persone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications on this device" xml:space="preserve">
|
||||||
|
<value>Notifiche su questo dispositivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here." xml:space="preserve">
|
||||||
|
<value>Le notifiche raggiungono questo dispositivo anche quando decePub è chiuso, per le persone e i tipi che scegli qui.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here." xml:space="preserve">
|
||||||
|
<value>Questo browser blocca le notifiche di decePub. Consentile nelle impostazioni del sito, poi attivale qui.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Whose notifications reach this device" xml:space="preserve">
|
||||||
|
<value>Di chi sono le notifiche che arrivano su questo dispositivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="People you follow" xml:space="preserve">
|
||||||
|
<value>Chi segui</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>Per provarle, mostra una notifica di prova: la mostra questo browser da solo, al server non arriva nulla. Una menzione o un preferito da un altro account arriva allo stesso modo, anche con decePub chiuso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show a test notification" xml:space="preserve">
|
||||||
|
<value>Mostra una notifica di prova</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>Solo questo dispositivo: un altro dispositivo sceglie per sé. Il servizio push di questo browser (quello di Google, di Mozilla, di Apple…) sa quando arriva una notifica, mai cosa dice.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>Su iPhone o iPad, aggiungi decePub alla schermata Home (Condividi, poi Aggiungi alla schermata Home) e aprilo da lì: Safari offre le notifiche solo a un'app installata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications need decePub opened over https." xml:space="preserve">
|
||||||
|
<value>Le notifiche richiedono decePub aperto in https.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser can't receive notifications from decePub while it is closed." xml:space="preserve">
|
||||||
|
<value>Questo browser non può ricevere notifiche da decePub mentre è chiuso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mentions and posts" xml:space="preserve">
|
||||||
|
<value>Menzioni e post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mentions" xml:space="preserve">
|
||||||
|
<value>Menzioni</value>
|
||||||
|
</data>
|
||||||
|
<data name="New posts from accounts you get notified of" xml:space="preserve">
|
||||||
|
<value>Nuovi post degli account di cui ricevi avvisi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quotes of your posts" xml:space="preserve">
|
||||||
|
<value>Citazioni dei tuoi post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edits of posts you interacted with" xml:space="preserve">
|
||||||
|
<value>Modifiche dei post con cui hai interagito</value>
|
||||||
|
</data>
|
||||||
|
<data name="Polls that ended" xml:space="preserve">
|
||||||
|
<value>Sondaggi terminati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reactions to your posts" xml:space="preserve">
|
||||||
|
<value>Reazioni ai tuoi post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Boosts of your posts" xml:space="preserve">
|
||||||
|
<value>Condivisioni dei tuoi post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Emoji reactions" xml:space="preserve">
|
||||||
|
<value>Reazioni con emoji</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who follows you" xml:space="preserve">
|
||||||
|
<value>Chi ti segue</value>
|
||||||
|
</data>
|
||||||
|
<data name="New followers" xml:space="preserve">
|
||||||
|
<value>Nuovi follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="From this server's staff" xml:space="preserve">
|
||||||
|
<value>Dallo staff di questo server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Warnings from the staff" xml:space="preserve">
|
||||||
|
<value>Avvisi dello staff</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not allow notifications." xml:space="preserve">
|
||||||
|
<value>Questo browser non ha consentito le notifiche.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications are on for this device." xml:space="preserve">
|
||||||
|
<value>Notifiche attive su questo dispositivo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notification settings saved for this device." xml:space="preserve">
|
||||||
|
<value>Impostazioni delle notifiche salvate per questo dispositivo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications are off for this device." xml:space="preserve">
|
||||||
|
<value>Notifiche disattivate su questo dispositivo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A test notification: this is how they show on this device." xml:space="preserve">
|
||||||
|
<value>Una notifica di prova: ecco come appaiono su questo dispositivo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not show the test notification." xml:space="preserve">
|
||||||
|
<value>Questo browser non ha mostrato la notifica di prova.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -3809,4 +3809,106 @@
|
|||||||
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
|
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
|
||||||
<value>{0} posts with it, the last on {1}</value>
|
<value>{0} posts with it, the last on {1}</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="This server does not offer push notifications." xml:space="preserve">
|
||||||
|
<value>This server does not offer push notifications.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not subscribe: {0}" xml:space="preserve">
|
||||||
|
<value>This browser did not subscribe: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications go through your browser's push service" xml:space="preserve">
|
||||||
|
<value>Notifications go through your browser's push service</value>
|
||||||
|
</data>
|
||||||
|
<data name="To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together." xml:space="preserve">
|
||||||
|
<value>To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications on this device" xml:space="preserve">
|
||||||
|
<value>Notifications on this device</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here." xml:space="preserve">
|
||||||
|
<value>Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here." xml:space="preserve">
|
||||||
|
<value>This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Whose notifications reach this device" xml:space="preserve">
|
||||||
|
<value>Whose notifications reach this device</value>
|
||||||
|
</data>
|
||||||
|
<data name="People you follow" xml:space="preserve">
|
||||||
|
<value>People you follow</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show a test notification" xml:space="preserve">
|
||||||
|
<value>Show a test notification</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications need decePub opened over https." xml:space="preserve">
|
||||||
|
<value>Notifications need decePub opened over https.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser can't receive notifications from decePub while it is closed." xml:space="preserve">
|
||||||
|
<value>This browser can't receive notifications from decePub while it is closed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mentions and posts" xml:space="preserve">
|
||||||
|
<value>Mentions and posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mentions" xml:space="preserve">
|
||||||
|
<value>Mentions</value>
|
||||||
|
</data>
|
||||||
|
<data name="New posts from accounts you get notified of" xml:space="preserve">
|
||||||
|
<value>New posts from accounts you get notified of</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quotes of your posts" xml:space="preserve">
|
||||||
|
<value>Quotes of your posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edits of posts you interacted with" xml:space="preserve">
|
||||||
|
<value>Edits of posts you interacted with</value>
|
||||||
|
</data>
|
||||||
|
<data name="Polls that ended" xml:space="preserve">
|
||||||
|
<value>Polls that ended</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reactions to your posts" xml:space="preserve">
|
||||||
|
<value>Reactions to your posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Boosts of your posts" xml:space="preserve">
|
||||||
|
<value>Boosts of your posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Emoji reactions" xml:space="preserve">
|
||||||
|
<value>Emoji reactions</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who follows you" xml:space="preserve">
|
||||||
|
<value>Who follows you</value>
|
||||||
|
</data>
|
||||||
|
<data name="New followers" xml:space="preserve">
|
||||||
|
<value>New followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="From this server's staff" xml:space="preserve">
|
||||||
|
<value>From this server's staff</value>
|
||||||
|
</data>
|
||||||
|
<data name="Warnings from the staff" xml:space="preserve">
|
||||||
|
<value>Warnings from the staff</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not allow notifications." xml:space="preserve">
|
||||||
|
<value>This browser did not allow notifications.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications are on for this device." xml:space="preserve">
|
||||||
|
<value>Notifications are on for this device.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notification settings saved for this device." xml:space="preserve">
|
||||||
|
<value>Notification settings saved for this device.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notifications are off for this device." xml:space="preserve">
|
||||||
|
<value>Notifications are off for this device.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A test notification: this is how they show on this device." xml:space="preserve">
|
||||||
|
<value>A test notification: this is how they show on this device.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This browser did not show the test notification." xml:space="preserve">
|
||||||
|
<value>This browser did not show the test notification.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -161,6 +161,35 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revokes the persona's token and forgets it, so the next request as the persona exchanges a new one with the scopes
|
||||||
|
/// the client asks for now (APIs.OAuth.Scopes): a token exchanged before the client asked for push can't subscribe to
|
||||||
|
/// Web Push (PrivaPub's 403). The persona stays current if it was.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> RenewPersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new WebResult();
|
||||||
|
var session = await authStateProvider.GetAuthData(cancellationToken);
|
||||||
|
if (session.PersonaTokens.GetValueOrDefault(personaId) is not { Length: > 0 } token)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// forgotten first, so no request uses it while it is revoked
|
||||||
|
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
|
||||||
|
using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["token"] = token,
|
||||||
|
["client_id"] = appConfiguration.ClientId
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
if (!revoked.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
result = await revoked.Content.ReadWebResult(revoked.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(RenewPersonaToken)}", cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The current persona's Mastodon account, or null.</summary>
|
/// <summary>The current persona's Mastodon account, or null.</summary>
|
||||||
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
|
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
+96
-27
@@ -20,7 +20,8 @@ namespace decePubClient.Services
|
|||||||
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
|
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
|
||||||
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
|
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
|
||||||
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
|
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
|
||||||
/// minutes. Every request PrivaPub answers is recorded in RequestStats.
|
/// minutes. Every request PrivaPub answers is recorded in RequestStats. The *As methods send a Mastodon API request as
|
||||||
|
/// a given persona of the login, whichever the session acts as: with that persona's own token, never another's.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IHttpService
|
public interface IHttpService
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,22 @@ namespace decePubClient.Services
|
|||||||
/// current. A failure comes back like any other.
|
/// current. A failure comes back like any other.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>A Mastodon API GET as that persona (its token exchanged when missing, without making it current).</summary>
|
||||||
|
Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>A Mastodon API POST as that persona (its token exchanged when missing, without making it current).</summary>
|
||||||
|
Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>A Mastodon API PUT as that persona (its token exchanged when missing, without making it current).</summary>
|
||||||
|
Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>A Mastodon API DELETE as that persona (its token exchanged when missing, without making it current).</summary>
|
||||||
|
Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
|
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
|
||||||
@@ -91,10 +108,40 @@ namespace decePubClient.Services
|
|||||||
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var (response, token) = await RequestPersonaToken(personaId, cancellationToken);
|
||||||
|
if (token is not null)
|
||||||
|
await authStateProvider.SetPersona(personaId, token, cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(GetAs), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||||
|
retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(PostAs), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||||
|
retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(PutAs), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||||
|
retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(DeleteAs), () => BuildRequest(HttpMethod.Delete, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||||
|
retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>The exchange itself (RFC 8693 on /oauth/token, PrivaPub's PersonaExchange): the response, and the persona's
|
||||||
|
/// new token when PrivaPub gave one. Nothing is stored here.</summary>
|
||||||
|
async Task<(HttpResponseMessage Response, string Token)> RequestPersonaToken(string personaId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var jwt = await authStateProvider.GetToken(cancellationToken);
|
var jwt = await authStateProvider.GetToken(cancellationToken);
|
||||||
if (jwt is null)
|
if (jwt is null)
|
||||||
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
return (SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]), null);
|
||||||
|
|
||||||
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
|
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -106,14 +153,16 @@ namespace decePubClient.Services
|
|||||||
["scope"] = APIs.OAuth.Scopes
|
["scope"] = APIs.OAuth.Scopes
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return response;
|
return (response, null);
|
||||||
|
|
||||||
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
|
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token?.AccessToken))
|
if (string.IsNullOrEmpty(token?.AccessToken))
|
||||||
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway);
|
{
|
||||||
|
response.Dispose();
|
||||||
|
return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway), null);
|
||||||
|
}
|
||||||
|
|
||||||
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken);
|
return (response, token.AccessToken);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
|
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
|
||||||
@@ -217,7 +266,7 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var request = requestFactory();
|
var request = requestFactory();
|
||||||
if (IsMastodon(request))
|
if (IsMastodon(request))
|
||||||
return await SendAsPersona(request, requestFactory, cancellationToken);
|
return await SendAsPersona((await authStateProvider.GetAuthData(cancellationToken)).PersonaId, request, requestFactory, cancellationToken);
|
||||||
|
|
||||||
var token = await authStateProvider.GetToken(cancellationToken);
|
var token = await authStateProvider.GetToken(cancellationToken);
|
||||||
if (token is null)
|
if (token is null)
|
||||||
@@ -236,24 +285,39 @@ namespace decePubClient.Services
|
|||||||
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>A Mastodon API request as the persona named, whichever the session acts as.</summary>
|
||||||
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
|
async Task<HttpResponseMessage> SendAsGivenPersona(string personaId, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
|
||||||
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
|
|
||||||
/// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
|
|
||||||
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
|
|
||||||
/// </summary>
|
|
||||||
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var session = await authStateProvider.GetAuthData(cancellationToken);
|
var request = requestFactory();
|
||||||
if (authStateProvider.IsRefused(session.PersonaId))
|
if (!IsMastodon(request) || personaId is null)
|
||||||
|
{
|
||||||
|
request.Dispose();
|
||||||
|
return SUtility.ClassifiedFailure(FailureCodes.ClientError,
|
||||||
|
localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SendAsPersona(personaId, request, requestFactory, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Mastodon API request as the persona (the current one, or the one an *As method names), with that persona's own
|
||||||
|
/// token. A missing token is exchanged first; a refused one (revoked, or PrivaPub's database was reset) is exchanged
|
||||||
|
/// once more and the request sent again. Without a usable JWT the session ends. A persona PrivaPub refuses a token to
|
||||||
|
/// while the JWT is good (suspended by the staff, or gone) is marked refused (TokenAuthStateProvider.Refuse, which
|
||||||
|
/// tells CascadingState once) and no request acts as it again.
|
||||||
|
/// </summary>
|
||||||
|
async Task<HttpResponseMessage> SendAsPersona(string personaId, HttpRequestMessage request, Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (authStateProvider.IsRefused(personaId))
|
||||||
return PersonaRefused();
|
return PersonaRefused();
|
||||||
|
|
||||||
var token = await authStateProvider.GetPersonaToken(cancellationToken);
|
var token = await authStateProvider.GetPersonaToken(personaId, cancellationToken);
|
||||||
if (token is null)
|
if (token is null)
|
||||||
{
|
{
|
||||||
var exchanged = await Exchange(session.PersonaId, cancellationToken);
|
var exchanged = await Exchange(personaId, cancellationToken);
|
||||||
if (exchanged.Refused)
|
if (exchanged.Refused)
|
||||||
return PersonaRefused(session.PersonaId);
|
return PersonaRefused(personaId);
|
||||||
token = exchanged.Token;
|
token = exchanged.Token;
|
||||||
}
|
}
|
||||||
if (token is null)
|
if (token is null)
|
||||||
@@ -265,10 +329,10 @@ namespace decePubClient.Services
|
|||||||
return response;
|
return response;
|
||||||
|
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
|
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
|
||||||
var renewed = await Exchange(session.PersonaId, cancellationToken);
|
var renewed = await Exchange(personaId, cancellationToken);
|
||||||
if (renewed.Refused)
|
if (renewed.Refused)
|
||||||
return PersonaRefused(session.PersonaId);
|
return PersonaRefused(personaId);
|
||||||
token = renewed.Token;
|
token = renewed.Token;
|
||||||
if (token is null)
|
if (token is null)
|
||||||
return await EndSession(cancellationToken);
|
return await EndSession(cancellationToken);
|
||||||
@@ -283,15 +347,20 @@ namespace decePubClient.Services
|
|||||||
return await EndSession(cancellationToken);
|
return await EndSession(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a
|
/// <summary>A new token for the persona, kept without making it current, or none; Refused when PrivaPub itself answered
|
||||||
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary>
|
/// no (its 400 invalid_grant or a 403) to a JWT the client still holds as good, rather than the request failing on the
|
||||||
|
/// way.</summary>
|
||||||
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
|
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (personaId is null)
|
if (personaId is null)
|
||||||
return (null, false);
|
return (null, false);
|
||||||
using var response = await ExchangePersonaToken(personaId, cancellationToken);
|
var (exchange, token) = await RequestPersonaToken(personaId, cancellationToken);
|
||||||
if (response.IsSuccessStatusCode)
|
using var response = exchange;
|
||||||
return (await authStateProvider.GetPersonaToken(cancellationToken), false);
|
if (token is not null)
|
||||||
|
{
|
||||||
|
await authStateProvider.SetPersonaToken(personaId, token, cancellationToken);
|
||||||
|
return (token, false);
|
||||||
|
}
|
||||||
|
|
||||||
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
|
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
|
||||||
&& !response.Content.Headers.Contains(SUtility.FailureHeader)
|
&& !response.Content.Headers.Contains(SUtility.FailureHeader)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
|
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
|
||||||
/// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm
|
/// that these are public and visible as that persona (an owner decision); and this device, once, before its first Web
|
||||||
/// returns true at once for a persona that already accepted it, and false when the reader declines.
|
/// Push subscription, to accept that the browser's push service learns when notifications arrive (PublicAct.Push, told
|
||||||
|
/// once for the device whichever persona asks). PublicNoticeHost shows the question; Confirm returns true at once for
|
||||||
|
/// what was accepted already, and false when the reader declines.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PublicNoticeService(ILocalStorageService localStorage)
|
public class PublicNoticeService(ILocalStorageService localStorage)
|
||||||
{
|
{
|
||||||
@@ -22,11 +24,11 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
|
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(personaId))
|
if (string.IsNullOrEmpty(personaId) && act != PublicAct.Push)
|
||||||
return false;
|
return false;
|
||||||
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
|
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
|
||||||
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
|
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
|
||||||
if (told.Contains(personaId))
|
if (act == PublicAct.Push ? seen.PushTold : told.Contains(personaId))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
Act = act;
|
Act = act;
|
||||||
@@ -38,6 +40,9 @@ namespace decePubClient.Services
|
|||||||
if (!accepted)
|
if (!accepted)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (act == PublicAct.Push)
|
||||||
|
seen.PushTold = true;
|
||||||
|
else
|
||||||
told.Add(personaId);
|
told.Add(personaId);
|
||||||
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
|
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
using Blazored.LocalStorage;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Helpers;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.User;
|
||||||
|
using PrivaPub.ClientModels.User.Avatar;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Web Push on this device (Mastodon's /api/v1/push/subscription, PrivaPub's R9), through wwwroot/js/push.js.
|
||||||
|
/// <para>
|
||||||
|
/// PrivaPub keeps one subscription per access token, so per persona, and pushes it that persona's notifications alone.
|
||||||
|
/// Each persona chosen here gets a service worker registration of its own in this browser (wwwroot/push-worker.js at
|
||||||
|
/// push/{slot}/, a random slot this device keeps in PushDevice), so its own endpoint and keys: PrivaPub stores nothing
|
||||||
|
/// that ties two personas of one login together, and the worker knows the persona from its scope without reading the
|
||||||
|
/// token Mastodon's payload carries. Each persona's subscription is made, read, changed and deleted with that
|
||||||
|
/// persona's own token (IHttpService's *As), never another's, whichever persona the session acts as.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A subscription ends with its token on PrivaPub (a revocation, a password change, two-factor sign-in turned on,
|
||||||
|
/// sessions ended, a suspension), so a persona whose token changes is subscribed again with the new one
|
||||||
|
/// (TokenAuthStateProvider.PersonaTokenChanged), and every start of the session checks them all (Reconcile). Signing
|
||||||
|
/// out ends them first (TurnOff); a deleted or suspended persona is dropped here (Drop). Failures come back as invalid
|
||||||
|
/// WebResults; nothing throws.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class PushService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AuthService authService, AboutService aboutService,
|
||||||
|
ILocalStorageService localStorage, IJSRuntime jsRuntime, ILoggingService logger, CoalescingStringLocalizer localizer) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>How long a changed token waits before its persona is subscribed again: one change often brings others.</summary>
|
||||||
|
static readonly TimeSpan RenewDelay = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
readonly SemaphoreSlim gate = new(1, 1);
|
||||||
|
readonly CancellationTokenSource stopping = new();
|
||||||
|
/// <summary>The personas whose tokens changed since their subscriptions were last made sure of.</summary>
|
||||||
|
readonly HashSet<string> stale = [];
|
||||||
|
/// <summary>The personas subscribed on this device, as last read or saved.</summary>
|
||||||
|
HashSet<string> subscribed = [];
|
||||||
|
IJSObjectReference module;
|
||||||
|
bool moduleFailed;
|
||||||
|
bool listening;
|
||||||
|
bool renewing;
|
||||||
|
/// <summary>The persona a request of this service acts as: the token changes its own exchanges make need no renewal.</summary>
|
||||||
|
string actingAs;
|
||||||
|
|
||||||
|
/// <summary>Whether this browser can be pushed to, and what the reader allowed it.</summary>
|
||||||
|
public async Task<PushSupport> Support(CancellationToken cancellationToken = default) =>
|
||||||
|
await Js("support", new PushSupport(), [], cancellationToken) ?? new();
|
||||||
|
|
||||||
|
/// <summary>The browser's own question, asked once; what it knows already is answered at once (granted, denied or
|
||||||
|
/// default). Call it from the reader's click.</summary>
|
||||||
|
public async Task<string> RequestPermission(CancellationToken cancellationToken = default) =>
|
||||||
|
await Js("requestPermission", "default", [], cancellationToken) ?? "default";
|
||||||
|
|
||||||
|
/// <summary>Whether the server gives a key to push with (the instance API's configuration.vapid): false for a server
|
||||||
|
/// that pushes nothing, null while the instance can't be read.</summary>
|
||||||
|
public async Task<bool?> ServerPushes(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var instance = await aboutService.Instance(cancellationToken: cancellationToken);
|
||||||
|
if (instance is null)
|
||||||
|
return null;
|
||||||
|
return instance.Configuration?.Vapid?.PublicKey is { Length: > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This device's choices: the personas subscribed, the alerts and the policy.</summary>
|
||||||
|
public async Task<PushDevice> Device(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var device = await localStorage.GetItemAsync<PushDevice>(nameof(PushDevice), cancellationToken) ?? new();
|
||||||
|
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona a registration's slot is for on this device (a notification's click), or null.</summary>
|
||||||
|
public async Task<string> PersonaOf(string slot, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(slot))
|
||||||
|
return null;
|
||||||
|
return (await Device(cancellationToken)).Personas.FirstOrDefault(p => p.Slot == slot)?.PersonaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes each persona chosen, with its own token, to the alerts and the policy chosen, and unsubscribes the others;
|
||||||
|
/// none chosen is push off on this device. The browser's permission must be granted already (RequestPermission). The
|
||||||
|
/// first failure comes back; the personas that succeeded stay subscribed.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> Apply(List<string> personaIds, List<string> alerts, PushPolicy policy, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Listen();
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
device.Alerts = [.. alerts.Where(alert => PushAlerts.Offered.Contains(alert))];
|
||||||
|
device.Policy = policy;
|
||||||
|
foreach (var dropped in device.Personas.Where(p => !personaIds.Contains(p.PersonaId)).ToList())
|
||||||
|
await Unsubscribe(device, dropped, true, cancellationToken);
|
||||||
|
|
||||||
|
var result = new WebResult();
|
||||||
|
var key = personaIds.Count == 0 ? null : await ServerKey(cancellationToken);
|
||||||
|
if (personaIds.Count > 0 && key is null)
|
||||||
|
{
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return result.Invalidate(localizer["This server does not offer push notifications."], (int)HttpStatusCode.NotImplemented);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var personaId in personaIds)
|
||||||
|
{
|
||||||
|
var pushed = await Push(device, personaId, key, cancellationToken);
|
||||||
|
if (pushed.IsInvalid && !result.IsInvalid)
|
||||||
|
result = pushed;
|
||||||
|
}
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Push off on this device: PrivaPub forgets each persona's subscription (asked with that persona's token, while
|
||||||
|
/// the session holds one), then the browser forgets the subscriptions and their registrations. Signing out does it first.</summary>
|
||||||
|
public Task<WebResult> TurnOff(CancellationToken cancellationToken = default) => Clear(true, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>The browser forgets every subscription and registration, PrivaPub is not asked: its subscriptions ended
|
||||||
|
/// already with the tokens (every session ended, the login deleted, a session that expired).</summary>
|
||||||
|
public Task<WebResult> Forget(CancellationToken cancellationToken = default) => Clear(false, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>A persona deleted, or suspended by the staff: PrivaPub ended its subscription with it, and this device drops
|
||||||
|
/// its own.</summary>
|
||||||
|
public async Task<WebResult> Drop(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
if (device.Personas.FirstOrDefault(p => p.PersonaId == personaId) is not { } entry)
|
||||||
|
return new WebResult();
|
||||||
|
|
||||||
|
await Unsubscribe(device, entry, false, cancellationToken);
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings this device in line once the session is read (CascadingState.LoadSession), and when Settings shows it:
|
||||||
|
/// registrations it keeps no persona for go; with the permission withdrawn, or push gone from the browser, every
|
||||||
|
/// subscription goes; a persona deleted, or suspended by the staff, is dropped (PrivaPub ended its subscription); each
|
||||||
|
/// other one is made sure of (Push), so a token renewed since, or a new endpoint, subscribes it again.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> Reconcile(List<ViewAvatar> personas, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Listen();
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
await DropStrays(device, cancellationToken);
|
||||||
|
if (device.Personas.Count == 0)
|
||||||
|
return new WebResult();
|
||||||
|
|
||||||
|
var support = await Support(cancellationToken);
|
||||||
|
if (!support.Supported || !support.IsGranted)
|
||||||
|
{
|
||||||
|
foreach (var entry in device.Personas.ToList())
|
||||||
|
await Unsubscribe(device, entry, true, cancellationToken);
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a list that couldn't be read drops nothing)
|
||||||
|
if (personas.Count > 0)
|
||||||
|
foreach (var gone in device.Personas.Where(p => personas.FirstOrDefault(a => a.Id == p.PersonaId) is not { AccountState: not ViewAvatarState.Suspended }).ToList())
|
||||||
|
await Unsubscribe(device, gone, false, cancellationToken);
|
||||||
|
|
||||||
|
var key = await ServerKey(cancellationToken);
|
||||||
|
if (key is not null)
|
||||||
|
foreach (var entry in device.Personas.ToList())
|
||||||
|
await Warn(await Push(device, entry.PersonaId, key, cancellationToken), nameof(Reconcile), cancellationToken);
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A notification shown by the persona's registration alone (the first subscribed one's, when it has none):
|
||||||
|
/// nothing goes to any server. False when there is none, or the browser refused.</summary>
|
||||||
|
public async Task<bool> Test(string personaId, string title, string body, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId) ?? device.Personas.FirstOrDefault();
|
||||||
|
if (entry is null)
|
||||||
|
return false;
|
||||||
|
return await Js("test", false, [entry.Slot, title, body], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Clear(bool tellServer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
foreach (var entry in device.Personas.ToList())
|
||||||
|
await Unsubscribe(device, entry, tellServer, cancellationToken);
|
||||||
|
await DropStrays(device, cancellationToken);
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes sure PrivaPub pushes the persona's notifications to its subscription in this browser as the device's alerts
|
||||||
|
/// and policy say. The browser's subscription is made when missing. PrivaPub's is read: left as it is when it matches,
|
||||||
|
/// given the device's alerts and policy (PUT) when only they differ, and made again (POST) when it is missing or holds
|
||||||
|
/// another endpoint (its token changed, or the push service gave a new endpoint). A persona PrivaPub refuses (suspended,
|
||||||
|
/// or gone) is dropped; a new one that fails leaves nothing behind.
|
||||||
|
/// </summary>
|
||||||
|
async Task<WebResult> Push(PushDevice device, string personaId, string key, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId);
|
||||||
|
var isNew = entry is null;
|
||||||
|
entry ??= new() { PersonaId = personaId, Slot = NewSlot() };
|
||||||
|
actingAs = personaId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var browser = await Js<BrowserSubscription>("subscribe", null, [entry.Slot, key], cancellationToken);
|
||||||
|
if (browser is not { Endpoint.Length: > 0, P256dh.Length: > 0, Auth.Length: > 0 })
|
||||||
|
{
|
||||||
|
if (isNew)
|
||||||
|
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||||
|
return new WebResult().Invalidate(localizer["This browser did not subscribe: {0}", browser?.Error ?? "?"], (int)HttpStatusCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await Tell(device, entry.PersonaId, browser, cancellationToken);
|
||||||
|
if (result.IsInvalid && (isNew || result.ErrorCode is FailureCodes.PersonaRefused))
|
||||||
|
{
|
||||||
|
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||||
|
device.Personas.Remove(entry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result.IsInvalid)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
entry.SubscriptionId = (result.Data as WebPushSubscription)?.Id ?? entry.SubscriptionId;
|
||||||
|
if (isNew)
|
||||||
|
device.Personas.Add(entry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
actingAs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PrivaPub's side of Push; Data is the WebPushSubscription it holds now.</summary>
|
||||||
|
async Task<WebResult> Tell(PushDevice device, string personaId, BrowserSubscription browser, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var data = Data(device);
|
||||||
|
var held = await Held(personaId, cancellationToken);
|
||||||
|
if (held.IsInvalid)
|
||||||
|
return held;
|
||||||
|
|
||||||
|
if (held.Data is WebPushSubscription current && current.Endpoint == browser.Endpoint)
|
||||||
|
{
|
||||||
|
if (Matches(current, data))
|
||||||
|
return held;
|
||||||
|
|
||||||
|
var updated = await Send(personaId, () => httpService.PutAs(personaId, APIs.Mastodon.PUT_PushSubscription, new PushDataForm { Data = data },
|
||||||
|
cancellationToken: cancellationToken), cancellationToken);
|
||||||
|
if (!updated.IsInvalid || updated.StatusCode is not (int)HttpStatusCode.NotFound)
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Send(personaId, () => httpService.PostAs(personaId, APIs.Mastodon.POST_PushSubscription, new PushSubscriptionForm
|
||||||
|
{
|
||||||
|
Subscription = new()
|
||||||
|
{
|
||||||
|
Endpoint = browser.Endpoint,
|
||||||
|
Keys = new() { P256dh = browser.P256dh, Auth = browser.Auth },
|
||||||
|
Standard = browser.Standard
|
||||||
|
},
|
||||||
|
Data = data
|
||||||
|
}, cancellationToken: cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The subscription PrivaPub holds for the persona's token as Data, or no Data when it holds none (its 404).</summary>
|
||||||
|
async Task<WebResult> Held(string personaId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await AsPersona(personaId, () => httpService.GetAs(personaId, APIs.Mastodon.GET_PushSubscription,
|
||||||
|
cancellationToken: cancellationToken), cancellationToken);
|
||||||
|
if (response.StatusCode is HttpStatusCode.NotFound)
|
||||||
|
return new WebResult();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<WebPushSubscription>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A POST or PUT of the persona's subscription; Data is what PrivaPub holds then.</summary>
|
||||||
|
async Task<WebResult> Send(string personaId, Func<Task<HttpResponseMessage>> send, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await AsPersona(personaId, send, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<WebPushSubscription>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A request as the persona. A token PrivaPub refuses for want of the push scope (one exchanged before decePub
|
||||||
|
/// asked for it) is revoked and forgotten, and the request sent once more with the new one the exchange gives.</summary>
|
||||||
|
async Task<HttpResponseMessage> AsPersona(string personaId, Func<Task<HttpResponseMessage>> send, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await send();
|
||||||
|
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
|
||||||
|
return response;
|
||||||
|
// (read once, the content stays buffered for ReadWebResult)
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!body.Contains("authorized scopes", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return response;
|
||||||
|
|
||||||
|
response.Dispose();
|
||||||
|
await authService.RenewPersonaToken(personaId, cancellationToken);
|
||||||
|
return await send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona goes from this device; PrivaPub is asked to forget its subscription first when tellServer and the
|
||||||
|
/// persona still holds a token (one forgotten took its subscription with it).</summary>
|
||||||
|
async Task Unsubscribe(PushDevice device, PushPersona entry, bool tellServer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (tellServer && !authStateProvider.IsRefused(entry.PersonaId) && await authStateProvider.IsAuthenticatedAsync(cancellationToken)
|
||||||
|
&& await authStateProvider.GetPersonaToken(entry.PersonaId, cancellationToken) is not null)
|
||||||
|
{
|
||||||
|
actingAs = entry.PersonaId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await httpService.DeleteAs(entry.PersonaId, APIs.Mastodon.DELETE_PushSubscription, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
await Warn(await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken), nameof(Unsubscribe), cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
actingAs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||||
|
device.Personas.Remove(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registrations at push/{slot}/ this device keeps no persona for (its storage cleared, or a failure half-way) go
|
||||||
|
/// with their subscriptions, so nothing PrivaPub still pushes to them is shown here.</summary>
|
||||||
|
async Task DropStrays(PushDevice device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var known = device.Personas.Select(p => p.Slot).ToHashSet();
|
||||||
|
foreach (var slot in await Js<List<string>>("slots", [], [], cancellationToken) ?? [])
|
||||||
|
if (!known.Contains(slot))
|
||||||
|
await Js("unsubscribe", false, [slot], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's VAPID key (the instance API's configuration.vapid.public_key), or null when it pushes nothing.</summary>
|
||||||
|
async Task<string> ServerKey(CancellationToken cancellationToken) =>
|
||||||
|
(await aboutService.Instance(cancellationToken: cancellationToken))?.Configuration?.Vapid?.PublicKey is { Length: > 0 } key ? key : null;
|
||||||
|
|
||||||
|
/// <summary>Every alert offered, on as the device has it; those PrivaPub never fires are left out, which is off.</summary>
|
||||||
|
static PushData Data(PushDevice device) => new()
|
||||||
|
{
|
||||||
|
Alerts = PushAlerts.Offered.ToDictionary(alert => alert, alert => device.Alerts.Contains(alert)),
|
||||||
|
Policy = PushPolicies.Name(device.Policy)
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool Matches(WebPushSubscription held, PushData data) =>
|
||||||
|
PushPolicies.Of(held.Policy) == PushPolicies.Of(data.Policy)
|
||||||
|
&& PushAlerts.Offered.All(alert => held.Alerts.GetValueOrDefault(alert) == data.Alerts.GetValueOrDefault(alert));
|
||||||
|
|
||||||
|
static string NewSlot() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
|
||||||
|
|
||||||
|
async Task Save(PushDevice device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
|
||||||
|
await localStorage.SetItemAsync(nameof(PushDevice), device, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>From the first Apply or Reconcile on: a persona whose token changes is subscribed again with the new one,
|
||||||
|
/// and a session that ends on its own (an expired or refused JWT) takes this device's subscriptions with it.</summary>
|
||||||
|
void Listen()
|
||||||
|
{
|
||||||
|
if (listening)
|
||||||
|
return;
|
||||||
|
listening = true;
|
||||||
|
authStateProvider.PersonaTokenChanged += OnTokenChanged;
|
||||||
|
authStateProvider.AuthenticationStateChanged += OnAuthenticationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnTokenChanged(string personaId)
|
||||||
|
{
|
||||||
|
if (personaId == actingAs || !subscribed.Contains(personaId) || !stale.Add(personaId) || renewing)
|
||||||
|
return;
|
||||||
|
_ = RenewSoon(stopping.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnAuthenticationChanged(Task<AuthenticationState> state) => _ = ForgetWhenSignedOut(state, stopping.Token);
|
||||||
|
|
||||||
|
async Task RenewSoon(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
renewing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(RenewDelay, cancellationToken);
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var device = await Device(cancellationToken);
|
||||||
|
var key = await ServerKey(cancellationToken);
|
||||||
|
while (stale.Count > 0)
|
||||||
|
{
|
||||||
|
var personaIds = stale.ToList();
|
||||||
|
stale.Clear();
|
||||||
|
if (key is null || !await authStateProvider.IsAuthenticatedAsync(cancellationToken))
|
||||||
|
continue;
|
||||||
|
foreach (var personaId in personaIds.Where(id => device.Personas.Any(p => p.PersonaId == id)))
|
||||||
|
await Warn(await Push(device, personaId, key, cancellationToken), nameof(RenewSoon), cancellationToken);
|
||||||
|
}
|
||||||
|
await Save(device, cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(RenewSoon)}", cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
renewing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ForgetWhenSignedOut(Task<AuthenticationState> state, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if ((await state).User.Identity?.IsAuthenticated == true || subscribed.Count == 0)
|
||||||
|
return;
|
||||||
|
await Forget(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(ForgetWhenSignedOut)}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Warn(WebResult result, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (result.IsInvalid && result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(PushService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A push.js function; its fallback when the module can't be loaded or the call fails (logged).</summary>
|
||||||
|
async Task<T> Js<T>(string function, T fallback, object[] args, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var push = await Module(cancellationToken);
|
||||||
|
if (push is null)
|
||||||
|
return fallback;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await push.InvokeAsync<T>(function, cancellationToken, args);
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
await logger.ProcessError(ex, $"{nameof(PushService)}.{function}", cancellationToken);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IJSObjectReference> Module(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (module is not null || moduleFailed)
|
||||||
|
return module;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return module = await jsRuntime.InvokeAsync<IJSObjectReference>("import", cancellationToken, "./js/push.js");
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
moduleFailed = true;
|
||||||
|
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(Module)}", cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
authStateProvider.PersonaTokenChanged -= OnTokenChanged;
|
||||||
|
authStateProvider.AuthenticationStateChanged -= OnAuthenticationChanged;
|
||||||
|
await stopping.CancelAsync();
|
||||||
|
stopping.Dispose();
|
||||||
|
if (module is not null)
|
||||||
|
await module.DisposeAsync();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ namespace decePubClient.Services
|
|||||||
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
|
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
|
||||||
public event Action<string> PersonaRefused;
|
public event Action<string> PersonaRefused;
|
||||||
|
|
||||||
|
/// <summary>Raised with a persona's id when its token is replaced or forgotten: a Web Push subscription made with the
|
||||||
|
/// old one ended with it (PushService subscribes the persona again with its new one).</summary>
|
||||||
|
public event Action<string> PersonaTokenChanged;
|
||||||
|
|
||||||
/// <summary>The stored session, or an empty one.</summary>
|
/// <summary>The stored session, or an empty one.</summary>
|
||||||
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
|
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -59,6 +63,15 @@ namespace decePubClient.Services
|
|||||||
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
|
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>That persona's Mastodon token, or null when it has none yet; never another persona's.</summary>
|
||||||
|
public async Task<string> GetPersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (personaId is null)
|
||||||
|
return null;
|
||||||
|
var session = await GetAuthData(cancellationToken);
|
||||||
|
return session.PersonaTokens.GetValueOrDefault(personaId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
|
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
|
||||||
await GetToken(cancellationToken) is not null;
|
await GetToken(cancellationToken) is not null;
|
||||||
|
|
||||||
@@ -81,9 +94,26 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var session = await GetAuthData(cancellationToken);
|
var session = await GetAuthData(cancellationToken);
|
||||||
session.PersonaId = personaId;
|
session.PersonaId = personaId;
|
||||||
|
var changed = token is not null && session.PersonaTokens.GetValueOrDefault(personaId) != token;
|
||||||
if (token is not null)
|
if (token is not null)
|
||||||
session.PersonaTokens[personaId] = token;
|
session.PersonaTokens[personaId] = token;
|
||||||
await Save(session, cancellationToken);
|
await Save(session, cancellationToken);
|
||||||
|
if (changed)
|
||||||
|
PersonaTokenChanged?.Invoke(personaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remembers a persona's new token without making it current (a request as another persona than the one
|
||||||
|
/// the session acts as, HttpService's *As).</summary>
|
||||||
|
public async Task SetPersonaToken(string personaId, string token, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (personaId is null || token is null)
|
||||||
|
return;
|
||||||
|
var session = await GetAuthData(cancellationToken);
|
||||||
|
if (session.PersonaTokens.GetValueOrDefault(personaId) == token)
|
||||||
|
return;
|
||||||
|
session.PersonaTokens[personaId] = token;
|
||||||
|
await Save(session, cancellationToken);
|
||||||
|
PersonaTokenChanged?.Invoke(personaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
|
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
|
||||||
@@ -93,6 +123,7 @@ namespace decePubClient.Services
|
|||||||
if (!session.PersonaTokens.Remove(personaId))
|
if (!session.PersonaTokens.Remove(personaId))
|
||||||
return;
|
return;
|
||||||
await Save(session, cancellationToken);
|
await Save(session, cancellationToken);
|
||||||
|
PersonaTokenChanged?.Invoke(personaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
|
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
|
||||||
@@ -120,8 +151,11 @@ namespace decePubClient.Services
|
|||||||
var session = await GetAuthData(cancellationToken);
|
var session = await GetAuthData(cancellationToken);
|
||||||
if (session.PersonaTokens.Count == 0)
|
if (session.PersonaTokens.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
var forgotten = session.PersonaTokens.Keys.ToList();
|
||||||
session.PersonaTokens.Clear();
|
session.PersonaTokens.Clear();
|
||||||
await Save(session, cancellationToken);
|
await Save(session, cancellationToken);
|
||||||
|
foreach (var personaId in forgotten)
|
||||||
|
PersonaTokenChanged?.Invoke(personaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ server {
|
|||||||
location = /appsettings.json { expires -1; }
|
location = /appsettings.json { expires -1; }
|
||||||
location = /service-worker.js { expires -1; }
|
location = /service-worker.js { expires -1; }
|
||||||
location = /service-worker-assets.js { expires -1; }
|
location = /service-worker-assets.js { expires -1; }
|
||||||
|
location = /push-worker.js { expires -1; }
|
||||||
|
|
||||||
location ^~ /_framework/ {
|
location ^~ /_framework/ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Web Push in this browser (Services/PushService.cs, imported on demand like js/feed.js). Each persona the reader turns
|
||||||
|
// notifications on for gets a service worker registration of its own: wwwroot/push-worker.js at the scope push/{slot}/,
|
||||||
|
// where the slot is a random name this device keeps. Its push subscription is that persona's alone, with its own
|
||||||
|
// endpoint and keys, so nothing PrivaPub stores ties two personas of one login together, and the worker knows the
|
||||||
|
// persona from its scope without ever reading the payload's token.
|
||||||
|
//
|
||||||
|
// What fails as browsers fail (no permission, a push service that refused) comes back as { error }; what fails
|
||||||
|
// unexpectedly is also reported through window.logFromJs.
|
||||||
|
|
||||||
|
const workerUrl = new URL("../push-worker.js", import.meta.url).href
|
||||||
|
const scopesRoot = new URL("../push/", import.meta.url).href
|
||||||
|
const scopeOf = slot => `${scopesRoot}${slot}/`
|
||||||
|
|
||||||
|
function report(e, where) {
|
||||||
|
console.error(e)
|
||||||
|
window.logFromJs?.(e?.message ?? String(e), `push.${where}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios() {
|
||||||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function installed() {
|
||||||
|
return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether this browser can be pushed to at all, why not, and what the reader allowed: default, granted or denied. On an
|
||||||
|
// iPhone or iPad, Safari offers Web Push only to the app added to the Home Screen and opened from there.
|
||||||
|
export function support() {
|
||||||
|
try {
|
||||||
|
const permission = typeof Notification == "undefined" ? "default" : Notification.permission
|
||||||
|
if (!window.isSecureContext)
|
||||||
|
return { supported: false, reason: "insecure", permission }
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification == "undefined" || !("showNotification" in ServiceWorkerRegistration.prototype))
|
||||||
|
return { supported: false, reason: ios() && !installed() ? "ios-install" : "unsupported", permission }
|
||||||
|
return { supported: true, reason: null, permission }
|
||||||
|
} catch (e) {
|
||||||
|
report(e, "support")
|
||||||
|
return { supported: false, reason: "unsupported", permission: "default" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asks the reader once (the browser's own prompt, after a click); what the browser already knows is answered at once.
|
||||||
|
export async function requestPermission() {
|
||||||
|
try {
|
||||||
|
if (Notification.permission !== "default")
|
||||||
|
return Notification.permission
|
||||||
|
return await Notification.requestPermission()
|
||||||
|
} catch (e) {
|
||||||
|
report(e, "requestPermission")
|
||||||
|
return typeof Notification == "undefined" ? "default" : Notification.permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registrationOf(slot, create) {
|
||||||
|
const scope = scopeOf(slot)
|
||||||
|
// (getRegistration(url) answers the registration that controls the URL: the app's own one when this one is missing)
|
||||||
|
let registration = (await navigator.serviceWorker.getRegistrations()).find(r => r.scope === scope)
|
||||||
|
if (!registration && create)
|
||||||
|
registration = await navigator.serviceWorker.register(workerUrl, { scope, updateViaCache: "none" })
|
||||||
|
if (registration)
|
||||||
|
await activated(registration)
|
||||||
|
return registration
|
||||||
|
}
|
||||||
|
|
||||||
|
// a push subscription needs an active worker
|
||||||
|
function activated(registration) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const worker = registration.installing ?? registration.waiting
|
||||||
|
if (registration.active || !worker)
|
||||||
|
return resolve()
|
||||||
|
const settle = () => {
|
||||||
|
if (worker.state === "activated" || worker.state === "redundant")
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
worker.addEventListener("statechange", settle)
|
||||||
|
setTimeout(resolve, 10000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(text) {
|
||||||
|
const normal = text.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const raw = atob(normal + "===".slice((normal.length + 3) % 4))
|
||||||
|
return Uint8Array.from(raw, c => c.charCodeAt(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameKey(buffer, bytes) {
|
||||||
|
// a browser that doesn't say which key it subscribed with is taken at its word
|
||||||
|
if (!buffer)
|
||||||
|
return true
|
||||||
|
const held = new Uint8Array(buffer)
|
||||||
|
return held.length === bytes.length && held.every((value, i) => value === bytes[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(subscription) {
|
||||||
|
const keys = subscription.toJSON().keys ?? {}
|
||||||
|
const encodings = PushManager.supportedContentEncodings
|
||||||
|
return {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: keys.p256dh ?? null,
|
||||||
|
auth: keys.auth ?? null,
|
||||||
|
// RFC 8291's aes128gcm, which every browser that pushes decrypts; the aesgcm draft only where it is all there is
|
||||||
|
standard: !Array.isArray(encodings) || encodings.includes("aes128gcm"),
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The persona's subscription in this browser, made (with its registration) when there is none, and made again when it
|
||||||
|
// was made with another server key. Its endpoint and its keys as URL-safe base64.
|
||||||
|
export async function subscribe(slot, serverKey) {
|
||||||
|
try {
|
||||||
|
if (Notification.permission !== "granted")
|
||||||
|
return { error: `permission ${Notification.permission}` }
|
||||||
|
const key = fromBase64Url(serverKey)
|
||||||
|
const registration = await registrationOf(slot, true)
|
||||||
|
let subscription = await registration.pushManager.getSubscription()
|
||||||
|
if (subscription && !sameKey(subscription.options?.applicationServerKey, key)) {
|
||||||
|
await subscription.unsubscribe()
|
||||||
|
subscription = null
|
||||||
|
}
|
||||||
|
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||||
|
return describe(subscription)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return { error: e?.message ?? String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The persona's subscription and its registration go: the push service forgets the endpoint, and refuses PrivaPub what it
|
||||||
|
// would still send there.
|
||||||
|
export async function unsubscribe(slot) {
|
||||||
|
try {
|
||||||
|
const registration = await registrationOf(slot, false)
|
||||||
|
if (!registration)
|
||||||
|
return true
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
await subscription?.unsubscribe()
|
||||||
|
return await registration.unregister()
|
||||||
|
} catch (e) {
|
||||||
|
report(e, "unsubscribe")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slots this browser holds registrations for, so the app drops those it no longer knows (a device whose storage was
|
||||||
|
// cleared would otherwise keep showing what PrivaPub pushes).
|
||||||
|
export async function slots() {
|
||||||
|
try {
|
||||||
|
if (!("serviceWorker" in navigator))
|
||||||
|
return []
|
||||||
|
return (await navigator.serviceWorker.getRegistrations())
|
||||||
|
.map(r => r.scope)
|
||||||
|
.filter(scope => scope.startsWith(scopesRoot))
|
||||||
|
.map(scope => scope.slice(scopesRoot.length).replace(/\/$/, ""))
|
||||||
|
.filter(name => name.length > 0)
|
||||||
|
} catch (e) {
|
||||||
|
report(e, "slots")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A notification shown by the persona's registration alone, nothing sent anywhere: it shows how they will look, and its
|
||||||
|
// click goes where theirs would.
|
||||||
|
export async function test(slot, title, body) {
|
||||||
|
try {
|
||||||
|
const registration = await registrationOf(slot, false)
|
||||||
|
if (!registration || Notification.permission !== "granted")
|
||||||
|
return false
|
||||||
|
await registration.showNotification(title, { body, tag: "decepub-test", icon: new URL("../imgs/icon-192.png", import.meta.url).href, data: {} })
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
report(e, "test")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-2
@@ -49,9 +49,13 @@ window.isOnline = async () => {
|
|||||||
window.logFromJs(e.message, "isOnline")
|
window.logFromJs(e.message, "isOnline")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The installed app's badge: the current persona's unread notifications (Index.razor). Browsers without the Badging API
|
||||||
|
// (Firefox, or a tab that isn't installed) show none, and say nothing about it.
|
||||||
window.setAppBadge = (counter) => {
|
window.setAppBadge = (counter) => {
|
||||||
try {
|
try {
|
||||||
navigator.setAppBadge(counter)
|
if (typeof navigator.setAppBadge != "function")
|
||||||
|
return
|
||||||
|
navigator.setAppBadge(counter)?.catch?.(e => console.debug(e))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
window.logFromJs(e.message, "setAppBadge")
|
window.logFromJs(e.message, "setAppBadge")
|
||||||
@@ -59,12 +63,34 @@ window.setAppBadge = (counter) => {
|
|||||||
}
|
}
|
||||||
window.clearAppBadge = () => {
|
window.clearAppBadge = () => {
|
||||||
try {
|
try {
|
||||||
navigator.clearAppBadge()
|
if (typeof navigator.clearAppBadge != "function")
|
||||||
|
return
|
||||||
|
navigator.clearAppBadge()?.catch?.(e => console.debug(e))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
window.logFromJs(e.message, "clearAppBadge")
|
window.logFromJs(e.message, "clearAppBadge")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A click on a Web Push notification while decePub is open: the persona's worker (push-worker.js) focuses this tab and
|
||||||
|
// says which of its registrations showed it, and the app opens that persona's notifications (CascadingState.PushOpened).
|
||||||
|
// Only this origin's workers can post here.
|
||||||
|
try {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener("message", event => {
|
||||||
|
try {
|
||||||
|
if (event.data?.type !== "decepub-push-open" || typeof event.data.slot != "string")
|
||||||
|
return
|
||||||
|
window.cascadingStateInstance?.invokeMethodAsync("PushOpened", event.data.slot)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
window.logFromJs(e.message, "pushOpened")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
navigator.serviceWorker.startMessages()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its
|
// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its
|
||||||
// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes.
|
// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes.
|
||||||
document.addEventListener("click", event => {
|
document.addEventListener("click", event => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// The service worker of one persona's Web Push subscription. wwwroot/js/push.js registers this script once per persona
|
||||||
|
// the reader turns notifications on for, at the scope push/{slot}/ (a random name this device keeps in localStorage's
|
||||||
|
// PushDevice, Services/PushService.cs), so each persona has a push subscription of its own: its own endpoint and keys,
|
||||||
|
// which PrivaPub stores with that persona's token alone. The same file serves development and published builds; the
|
||||||
|
// app's own workers (service-worker.js, service-worker.published.js) receive no pushes. It controls no page, so it has no
|
||||||
|
// fetch handler.
|
||||||
|
//
|
||||||
|
// A push shows the payload's title, body and icon, tagged with its notification id so a repeat replaces it. The payload
|
||||||
|
// is Mastodon's, so it also carries the subscription's access token: that is never read, kept, shown or passed on here.
|
||||||
|
// A click opens decePub on the notifications of the persona this registration is for: an open decePub tab is focused and
|
||||||
|
// told which (main.js, CascadingState.PushOpened), else a new one opens at ?notifications=1&push={slot}.
|
||||||
|
|
||||||
|
const appRoot = new URL("./", self.location).href
|
||||||
|
const slot = new URL(self.registration.scope).pathname.split("/").filter(part => part.length > 0).pop()
|
||||||
|
|
||||||
|
self.addEventListener("install", () => self.skipWaiting())
|
||||||
|
|
||||||
|
self.addEventListener("push", event => event.waitUntil(show(event.data)))
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", event => {
|
||||||
|
event.notification.close()
|
||||||
|
event.waitUntil(open())
|
||||||
|
})
|
||||||
|
|
||||||
|
// the push service changed the subscription: the same key subscribes again here, and the app tells PrivaPub the new
|
||||||
|
// endpoint the next time it starts (PushService.Reconcile), since this worker holds no token to tell it with
|
||||||
|
self.addEventListener("pushsubscriptionchange", event => event.waitUntil(renew(event)))
|
||||||
|
|
||||||
|
async function show(data) {
|
||||||
|
let title = "decePub"
|
||||||
|
const options = { icon: appRoot + "imgs/icon-192.png", timestamp: Date.now(), data: {} }
|
||||||
|
try {
|
||||||
|
const payload = data ? data.json() : null
|
||||||
|
if (payload && typeof payload == "object") {
|
||||||
|
if (typeof payload.title == "string" && payload.title.length > 0)
|
||||||
|
title = payload.title
|
||||||
|
if (typeof payload.body == "string")
|
||||||
|
options.body = payload.body
|
||||||
|
if (isWebAddress(payload.icon))
|
||||||
|
options.icon = payload.icon
|
||||||
|
if (payload.notification_id != null)
|
||||||
|
options.tag = String(payload.notification_id)
|
||||||
|
if (typeof payload.preferred_locale == "string")
|
||||||
|
options.lang = payload.preferred_locale
|
||||||
|
options.data = { notificationId: options.tag ?? null, type: typeof payload.notification_type == "string" ? payload.notification_type : null }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
// a push always shows something (userVisibleOnly): one that can't be read shows decePub's name alone
|
||||||
|
await self.registration.showNotification(title, options)
|
||||||
|
try {
|
||||||
|
// the count is the app's to say (Index.razor); until it opens, a dot says something waits
|
||||||
|
await self.navigator.setAppBadge?.()
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebAddress(value) {
|
||||||
|
if (typeof value != "string")
|
||||||
|
return false
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
|
||||||
|
const app = windows.filter(client => client.url.startsWith(appRoot))
|
||||||
|
const target = app.find(client => client.focused) ?? app.find(client => client.visibilityState === "visible") ?? app[0]
|
||||||
|
if (target) {
|
||||||
|
try {
|
||||||
|
await target.focus()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
// (navigate() is only for the worker that controls the tab: the app's own one does)
|
||||||
|
target.postMessage({ type: "decepub-push-open", slot })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(appRoot)
|
||||||
|
url.searchParams.set("notifications", "1")
|
||||||
|
url.searchParams.set("push", slot)
|
||||||
|
await self.clients.openWindow(url.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew(event) {
|
||||||
|
try {
|
||||||
|
const key = event.oldSubscription?.options?.applicationServerKey
|
||||||
|
if (!key || event.newSubscription)
|
||||||
|
return
|
||||||
|
await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// In development, always fetch from the network and do not enable offline support.
|
// In development, always fetch from the network and do not enable offline support.
|
||||||
// This is because caching would make development more difficult (changes would not
|
// This is because caching would make development more difficult (changes would not
|
||||||
// be reflected on the first load after each change).
|
// be reflected on the first load after each change).
|
||||||
|
// Web Push has workers of its own, one registration per persona at push/{slot}/ (push-worker.js, the same file in
|
||||||
|
// development and published builds): this worker receives no pushes.
|
||||||
self.addEventListener('fetch', () => { });
|
self.addEventListener('fetch', () => { });
|
||||||
@@ -9,8 +9,10 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
|
|||||||
const cacheNamePrefix = 'offline-cache-';
|
const cacheNamePrefix = 'offline-cache-';
|
||||||
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
||||||
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
|
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
|
||||||
// The globe (4 MB of script and textures) is fetched when the feed first shows it, never precached.
|
// The globe (4 MB of script and textures) is fetched when the feed first shows it, never precached. The personas' Web Push
|
||||||
const offlineAssetsExclude = [ /^service-worker\.js$/, /^vendor\/globe\// ];
|
// worker (push-worker.js, its own registrations at push/{slot}/) is fetched by the browser as a worker script, which no
|
||||||
|
// service worker serves, so it isn't precached either; this worker receives no pushes.
|
||||||
|
const offlineAssetsExclude = [ /^service-worker\.js$/, /^push-worker\.js$/, /^vendor\/globe\// ];
|
||||||
|
|
||||||
async function onInstall(event) {
|
async function onInstall(event) {
|
||||||
console.info('Service worker: Install');
|
console.info('Service worker: Install');
|
||||||
|
|||||||
Reference in new issue
Block a user