summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-11-08 16:28:10 -0500
committerOwen Jacobson <owen@grimoire.ca>2025-11-08 16:28:10 -0500
commitfc6914831743f6d683c59adb367479defe6f8b3a (patch)
tree5b997adac55f47b52f30022013b8ec3b2c10bcc5 /ui
parent0ef69c7d256380e660edc45ace7f1d6151226340 (diff)
parent6bab5b4405c9adafb2ce76540595a62eea80acc0 (diff)
Integrate the prototype push notification support.
We're going to move forwards with this for now, as low-utility as it is, so that we can more easily iterate on it in a real-world environment (hi.grimoire.ca).
Diffstat (limited to 'ui')
-rw-r--r--ui/lib/apiServer.js10
-rw-r--r--ui/lib/components/PushSubscription.svelte30
-rw-r--r--ui/lib/session.svelte.js19
-rw-r--r--ui/lib/state/local/push.svelte.js141
-rw-r--r--ui/routes/(app)/me/+page.svelte16
-rw-r--r--ui/routes/(swatch)/.swatch/+page.svelte1
-rw-r--r--ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte79
-rw-r--r--ui/service-worker.js9
8 files changed, 298 insertions, 7 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index ac707a5..9eeb128 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -47,7 +47,7 @@ export async function getInvite(inviteId) {
}
export async function acceptInvite(inviteId, name, password) {
- return apiServer
+ return await apiServer
.post(`/invite/${inviteId}`, {
name,
password,
@@ -55,6 +55,14 @@ export async function acceptInvite(inviteId, name, password) {
.catch(responseError);
}
+export async function createPushSubscription(subscription, vapid) {
+ return await apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError);
+}
+
+export async function sendPing() {
+ return await apiServer.post('/push/ping', {}).catch(responseError);
+}
+
export function subscribeToEvents(resumePoint) {
const eventsUrl = apiServer.getUri({
url: '/events',
diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte
new file mode 100644
index 0000000..aab4929
--- /dev/null
+++ b/ui/lib/components/PushSubscription.svelte
@@ -0,0 +1,30 @@
+<script>
+ let { vapid, subscription, subscribe = async () => null, ping = async () => null } = $props();
+ let pending = $state(false);
+
+ function onsubmit(callback) {
+ return async (evt) => {
+ evt.preventDefault();
+
+ pending = true;
+ try {
+ await callback();
+ } finally {
+ pending = false;
+ }
+ };
+ }
+</script>
+
+{#if !!vapid}
+ {#if !subscription}
+ <form class="form" onsubmit={onsubmit(subscribe)}>
+ <button disabled={pending} type="submit">create push subscription</button>
+ </form>
+ {/if}
+ <form class="form" onsubmit={onsubmit(ping)}>
+ <button disabled={pending} type="submit">send test notification</button>
+ </form>
+{:else}
+ Waiting for VAPID key…
+{/if}
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index c415d0c..cd41aa4 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -5,6 +5,7 @@ import { goto } from '$app/navigation';
import * as api from './apiServer.js';
import * as r from './state/remote/state.svelte.js';
import * as l from './state/local/conversations.svelte.js';
+import * as p from './state/local/push.svelte.js';
import { Watchdog } from './watchdog.js';
import { DateTime } from 'luxon';
@@ -51,6 +52,7 @@ class Message {
class Session {
remote = $state();
local = $state();
+ push = $state();
currentUser = $derived(this.remote.currentUser);
users = $derived(this.remote.users.all);
messages = $derived(
@@ -62,7 +64,7 @@ class Session {
),
);
- static boot({ login, resume_point, heartbeat, events }) {
+ static async boot({ login, resume_point, heartbeat, events }) {
const remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
@@ -70,22 +72,25 @@ class Session {
events,
});
const local = l.Conversations.fromLocalStorage();
- return new Session(remote, local);
+ const push = await p.Push.boot(events);
+ return new Session(remote, local, push);
}
- reboot({ login, resume_point, heartbeat, events }) {
+ async reboot({ login, resume_point, heartbeat, events }) {
this.remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
heartbeat,
events,
});
+ this.push = await p.Push.boot(events);
}
- constructor(remote, local) {
+ constructor(remote, local, push) {
this.watchdog = new Watchdog(this.watchdogExpired.bind(this));
this.remote = remote;
this.local = local;
+ this.push = push;
}
begin() {
@@ -107,6 +112,7 @@ class Session {
onMessage(message) {
const event = JSON.parse(message.data);
this.remote.onEvent(event);
+ this.push.onEvent(event);
this.local.retainConversations(this.remote.conversations.all);
this.watchdog.reset(this.heartbeatMillis());
}
@@ -127,7 +133,7 @@ class Session {
// which the session may have been abandoned.
if (!this.active()) return;
- this.reboot(response);
+ await this.reboot(response);
this.begin();
}
}
@@ -139,6 +145,7 @@ async function bootOrNavigate(navigateTo) {
} catch (err) {
switch (true) {
case err instanceof api.LoggedOut:
+ await this.push.unsubscribe();
await navigateTo('/login');
break;
case err instanceof api.SetupRequired:
@@ -152,5 +159,5 @@ async function bootOrNavigate(navigateTo) {
export async function boot() {
const response = await bootOrNavigate(async (url) => redirect(307, url));
- return Session.boot(response);
+ return await Session.boot(response);
}
diff --git a/ui/lib/state/local/push.svelte.js b/ui/lib/state/local/push.svelte.js
new file mode 100644
index 0000000..82846b1
--- /dev/null
+++ b/ui/lib/state/local/push.svelte.js
@@ -0,0 +1,141 @@
+import * as api from '$lib/apiServer.js';
+
+// In a few places in this module, I've used suffix to identify which type we're working with:
+// * ...Base64 is a string containing a URL-safe base64 value;
+// * ...Buffer is an ArrayBuffer holding untyped bytes; and
+// * ...Bytes is a Uint8Array holding typed bytes.
+// Working with byte streams in JS is _fun_.
+
+export class Push {
+ static async boot(events) {
+ const serviceWorker = await navigator.serviceWorker.ready;
+ const pushManager = serviceWorker.pushManager;
+ const subscription = await serviceWorker.pushManager.getSubscription();
+
+ const push = new Push(pushManager, subscription);
+
+ for (const event of events) {
+ push.onEvent(event);
+ }
+
+ return push;
+ }
+
+ vapidKey = $state(null);
+ subscription = $state(null);
+
+ constructor(pushManager, subscription) {
+ this.pushManager = pushManager;
+ this.subscription = subscription;
+ }
+
+ async hasPermission() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+
+ const state = await this.pushManager.permissionState(pushOptions);
+ return state === 'granted';
+ }
+
+ async subscribe() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+ this.subscription = await this.pushManager.subscribe(pushOptions);
+
+ const subscriptionJSON = this.subscription.toJSON();
+ const vapidKeyBytes = new Uint8Array(vapidKeyBuffer);
+ const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' });
+ await api.createPushSubscription(subscriptionJSON, vapidKeyBase64);
+ }
+
+ async resubscribe() {
+ if (this.subscription !== null) {
+ // If we have a subscription but it's not for the current VAPID key, then the VAPID key has
+ // been rotated and we need to replace the subscription. The server cannot deliver messages to
+ // subscriptions associated with the old VAPID key after rotation.
+
+ // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer,
+ // regardless of the representation passed into `subscribe` to create the subscription:
+ //
+ // <https://w3c.github.io/push-api/#pushsubscriptionoptions-interface>
+ if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) {
+ // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old
+ // subscription first - some UAs (Firefox) want subscriptions within an origin to share a
+ // consistent VAPID key, and we're explicitly changing the VAPID key here.
+ await this.unsubscribe();
+ await this.subscribe();
+ }
+ } else if (await this.hasPermission()) {
+ // If we have permission to create push subscriptions, but no push subscription, then set up
+ // a new subscription. This primarily happens after logging into Pilcrow if the user
+ // previously had a push subscription and has logged out.
+ await this.subscribe();
+ }
+ }
+
+ async unsubscribe() {
+ if (this.subscription !== null) {
+ await this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+ }
+
+ onEvent(event) {
+ switch (event.type) {
+ case 'vapid':
+ return this.onVapidEvent(event);
+ }
+ }
+
+ onVapidEvent(event) {
+ switch (event.event) {
+ case 'changed':
+ return this.onVapidChanged(event);
+ }
+ }
+
+ onVapidChanged(event) {
+ const { key: vapidKeyBase64 } = event;
+
+ // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because
+ // of the APIs involved, but it makes comparing the key to the current subscription's key
+ // (which is also provided as an ArrayBuffer) much easier.
+ const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, {
+ alphabet: 'base64url',
+ });
+ // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in
+ // principle it could be a subset of the underlying buffer, and there's very little downside to
+ // being meticulous here.
+ this.vapidKey = vapidKeyBytes.buffer.slice(
+ vapidKeyBytes.byteOffset,
+ vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength,
+ );
+
+ // Note that `resubscribe()` is async; this will start the [re]subscription process but will
+ // return to the caller before it completes. I'm not willing to make the entire event handling
+ // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed
+ // to support that (we could make it work), and we can't wait for async functions in a non-async
+ // context, so this is the best we can do.
+ this.resubscribe();
+ }
+}
+
+function equalArrayBuffers(aBuffer, bBuffer) {
+ // You might be thinking, surely there's a way to compare two array buffers for equality. I
+ // certainly expected that there would be. [Nope].
+ //
+ // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal
+ //
+ // The algorithm here is simple enough not to need an external dependency. However, this
+ // comparison is not designed to deal with detached ArrayBuffer instances.
+ const aBytes = new Uint8Array(aBuffer);
+ const bBytes = new Uint8Array(bBuffer);
+
+ return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]);
+}
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 0c960c8..a21d160 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -2,10 +2,16 @@
import LogOut from '$lib/components/LogOut.svelte';
import Invites from '$lib/components/Invites.svelte';
import ChangePassword from '$lib/components/ChangePassword.svelte';
+ import PushSubscription from '$lib/components/PushSubscription.svelte';
import { goto } from '$app/navigation';
import * as api from '$lib/apiServer.js';
+ const { data } = $props();
+ const { session } = data;
+ const subscription = $derived(session.push.subscription);
+ const vapid = $derived(session.push.vapidKey);
+
let invites = $state([]);
async function logOut() {
@@ -25,10 +31,20 @@
invites.push(response.data);
}
}
+
+ async function subscribe() {
+ await session.push.subscribe();
+ }
+
+ async function ping() {
+ await api.sendPing();
+ }
</script>
<ChangePassword {changePassword} />
<hr />
+<PushSubscription {subscription} {vapid} {subscribe} {ping} />
+<hr />
<Invites {invites} {createInvite} />
<hr />
<LogOut {logOut} />
diff --git a/ui/routes/(swatch)/.swatch/+page.svelte b/ui/routes/(swatch)/.swatch/+page.svelte
index 5334438..c1969e5 100644
--- a/ui/routes/(swatch)/.swatch/+page.svelte
+++ b/ui/routes/(swatch)/.swatch/+page.svelte
@@ -19,5 +19,6 @@
<li><a href="MessageRun">MessageRun</a></li>
<li><a href="MessageInput">MessageInput</a></li>
<li><a href="Message">Message</a></li>
+ <li><a href="PushSubscription">PushSubscription</a></li>
<li><a href="swatch/EventLog">swatch/EventLog</a></li>
</ul>
diff --git a/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte
new file mode 100644
index 0000000..3d564a3
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte
@@ -0,0 +1,79 @@
+<script>
+ import { DateTime } from 'luxon';
+
+ import PushSubscription from '$lib/components/PushSubscription.svelte';
+ import { makeDeriver } from '$lib/swatch/derive.js';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+ import * as json from '$lib/swatch/json.js';
+
+ function fromBase64(str) {
+ if (str.trim().length === 0) {
+ return null;
+ }
+ const bytes = Uint8Array.fromBase64(str, { alphabet: 'base64url' });
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ }
+
+ const base64Deriver = makeDeriver(fromBase64);
+
+ // This is a "real" key, but it's not a key that's in use anywhere. I generated it for this
+ // purpose. -o
+ const testVapidKey =
+ 'BJXUH-WxM8BoxntTsrLufxc2Zlbwk-A1wsF01-ykUyh9pSUZG1Ymk3R-FOxJTGApQeJWIYTW9j-1sLQFIL8cGBU=';
+
+ let vapidInput = $state('');
+ let vapid = $derived(base64Deriver(vapidInput));
+
+ // See <> for schema. This is an approximation of the browser subscription object.
+ const testSubscription = json.encode({
+ endpoint: 'https://push.example.com/1234',
+ expirationTime: performance.now() + 86400 /* sec */ * 1000 /* millisec */,
+ options: {
+ userVisibleOnly: true,
+ applicationServerKey: null,
+ },
+ });
+
+ let subscriptionInput = $state('');
+ let subscription = $derived(json.decode(subscriptionInput));
+
+ let capture = $state(new EventCapture());
+ const subscribe = capture.on('subscribe');
+ const ping = capture.on('ping');
+</script>
+
+<h1><code>PushSubscription</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>vapid key <input type="text" bind:value={vapidInput} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (vapidInput = '')}>(none)</button>
+ <button onclick={() => (vapidInput = testVapidKey)}>test key</button>
+ </div>
+
+ <label
+ ><p>subscription (json)</p>
+ <textarea bind:value={subscriptionInput}></textarea>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (subscriptionInput = '')}>(none)</button>
+ <button onclick={() => (subscriptionInput = testSubscription)}>example</button>
+ </div>
+ </label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <PushSubscription {vapid} {subscription} {subscribe} {ping} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/service-worker.js b/ui/service-worker.js
index d9b2a7c..cb32d0d 100644
--- a/ui/service-worker.js
+++ b/ui/service-worker.js
@@ -52,3 +52,12 @@ async function cacheFirst(request) {
self.addEventListener('fetch', (event) => {
event.respondWith(cacheFirst(event.request));
});
+
+self.addEventListener('push', (event) => {
+ event.waitUntil(
+ self.registration.showNotification('Test notification', {
+ actions: [],
+ body: event.data.text(),
+ }),
+ );
+});