Add Web Push notifications (PWA)

Wires up the previously-scaffolded VAPID/DeviceToken infrastructure end
to end: browser subscription flow on the Profile page's existing "Push
Notifications" toggle, a service worker push/notificationclick handler,
and server-side sending from the daily task digest. Also broadens that
digest's eligibility query so push-only users (email notifications off)
aren't silently skipped, and adds `generate_vapid_keys` since the pinned
py-vapid's own key generator is broken against current cryptography.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-09-04 22:30:56 -06:00
co-authored by Claude Sonnet 5
parent cf37389655
commit 7e905a0566
12 changed files with 226 additions and 27 deletions
+84
View File
@@ -20,6 +20,90 @@ function registerServiceWorker() {
}
}
/* ============================================
Push Notifications
============================================ */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function subscribeToPush(vapidPublicKey) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications are not supported in this browser.');
}
if (!vapidPublicKey) {
throw new Error('Push notifications are not configured on this server.');
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission was not granted.');
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const response = await fetch('/api/users/devices/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
platform: 'web',
token: JSON.stringify(subscription),
device_name: navigator.userAgent.slice(0, 255),
}),
});
if (!response.ok) {
throw new Error('Failed to register push subscription with the server.');
}
}
function handleProfileFormSubmit(event, form) {
const pushCheckbox = form.querySelector('[name=push_notifications]');
if (!pushCheckbox || !pushCheckbox.checked) {
return;
}
// preventDefault() must happen synchronously (before any await) or the
// browser will have already submitted the form by the time we get to it.
event.preventDefault();
enablePushThenSubmitProfile(form, pushCheckbox);
}
async function enablePushThenSubmitProfile(form, pushCheckbox) {
try {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const registration = await navigator.serviceWorker.getRegistration();
const existing = registration && await registration.pushManager.getSubscription();
if (!existing) {
await subscribeToPush(pushCheckbox.dataset.vapidPublicKey);
}
}
} catch (err) {
alert('Could not enable push notifications: ' + err.message);
pushCheckbox.checked = false;
}
form.submit();
}
/* ============================================
Theme Toggle
============================================ */