summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-11-07 21:39:39 -0500
committerOwen Jacobson <owen@grimoire.ca>2025-11-08 16:03:40 -0500
commit6bab5b4405c9adafb2ce76540595a62eea80acc0 (patch)
tree5b997adac55f47b52f30022013b8ec3b2c10bcc5 /ui
parent9be808177a06b33892be6fdd7c1cb31cf3b924fa (diff)
De minimis "send me a notification" implementation.
When a user clicks "send a test notification," Pilcrow delivers a push message (with a fixed payload) to all active subscriptions. The included client then displays this as a notification, using browser APIs to do so. This lets us verify that push notification works, end to end - and it appears to. The API endpoint for sending a test notification is not documented. I didn't feel it prudent to extensively document an endpoint that is intended to be temporary and whose side effects are very much subject to change. However, for posterity, the endpoint is POST /api/push/ping {} and the push message payload is ping Subscriptions with permanent delivery failures are nuked when we encounter them. Subscriptions with temporary failures cause the `ping` endpoint to return an internal server error, and are not retried. We'll likely want retry logic - including retry logic to handle server restarts - for any more serious use, but for a smoke test, giving up immediately is fine. To make the push implementation testable, `App` is now generic over it. Tests use a dummy implementation that stores sent messages in memory. This has some significant limitations, documented in the test suite, but it beats sending real notifications to nowhere in tests.
Diffstat (limited to 'ui')
-rw-r--r--ui/lib/apiServer.js8
-rw-r--r--ui/lib/components/PushSubscription.svelte9
-rw-r--r--ui/routes/(app)/me/+page.svelte6
-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
6 files changed, 106 insertions, 6 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index f55f271..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,
@@ -56,7 +56,11 @@ export async function acceptInvite(inviteId, name, password) {
}
export async function createPushSubscription(subscription, vapid) {
- return apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError);
+ 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) {
diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte
index a85cbb3..aab4929 100644
--- a/ui/lib/components/PushSubscription.svelte
+++ b/ui/lib/components/PushSubscription.svelte
@@ -1,5 +1,5 @@
<script>
- let { vapid, subscription, subscribe = async () => null } = $props();
+ let { vapid, subscription, subscribe = async () => null, ping = async () => null } = $props();
let pending = $state(false);
function onsubmit(callback) {
@@ -16,12 +16,15 @@
}
</script>
-{#if vapid !== null}
- {#if subscription === null}
+{#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/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index ddb1245..a21d160 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -35,11 +35,15 @@
async function subscribe() {
await session.push.subscribe();
}
+
+ async function ping() {
+ await api.sendPing();
+ }
</script>
<ChangePassword {changePassword} />
<hr />
-<PushSubscription {subscription} {vapid} {subscribe} />
+<PushSubscription {subscription} {vapid} {subscribe} {ping} />
<hr />
<Invites {invites} {createInvite} />
<hr />
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(),
+ }),
+ );
+});