feat: pwa notifications
This commit is contained in:
@@ -16,6 +16,45 @@ self.addEventListener("activate", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (e) {
|
||||
payload = { title: "Osiris", body: event.data ? event.data.text() : "" };
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || "Osiris", {
|
||||
body: payload.body || "",
|
||||
icon: "/static/icons/icon-192.png",
|
||||
badge: "/static/icons/icon-192.png",
|
||||
// Same tag replaces the previous notification instead of stacking.
|
||||
tag: payload.tag || "osiris",
|
||||
renotify: true,
|
||||
requireInteraction: true, // A dose reminder should wait, not fade away.
|
||||
data: { url: payload.url || "/admin/today/" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data && event.notification.data.url) || "/admin/today/";
|
||||
|
||||
// Focus the app if it is already open rather than opening a second window.
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((windows) => {
|
||||
for (const client of windows) {
|
||||
if (client.url.includes(url) && "focus" in client) return client.focus();
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
|
||||
|
||||
@@ -145,5 +145,125 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="osiris-card" id="reminders" hidden>
|
||||
<h2 style="margin-top:0">Reminders</h2>
|
||||
<p class="dose-detail" id="reminder-status">Checking…</p>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap">
|
||||
<button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px"></button>
|
||||
<button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden>
|
||||
Send test
|
||||
</button>
|
||||
</div>
|
||||
<p class="dose-detail" style="margin-bottom:0">
|
||||
A reminder is sent at each dose's reminder time, and only if it isn't already ticked off.
|
||||
Set those times on the medication in the admin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication & history →</a></p>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
|
||||
const card = document.getElementById("reminders");
|
||||
const status = document.getElementById("reminder-status");
|
||||
const toggle = document.getElementById("reminder-toggle");
|
||||
const test = document.getElementById("reminder-test");
|
||||
|
||||
// No keys configured, or a browser without push: leave the card hidden entirely.
|
||||
if (!VAPID_PUBLIC_KEY || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
|
||||
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
||||
|
||||
function post(url, body) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": csrf },
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || `Request failed (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// The browser wants the key as a Uint8Array, not the base64url we store it as.
|
||||
function decodeKey(key) {
|
||||
const padded = (key + "=".repeat((4 - (key.length % 4)) % 4))
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
const raw = atob(padded);
|
||||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
let subscription = null;
|
||||
|
||||
function render() {
|
||||
const blocked = Notification.permission === "denied";
|
||||
if (blocked) {
|
||||
status.textContent =
|
||||
"Notifications are blocked for this site. Re-allow them in Chrome's site settings.";
|
||||
toggle.hidden = true;
|
||||
test.hidden = true;
|
||||
return;
|
||||
}
|
||||
toggle.hidden = false;
|
||||
toggle.disabled = false;
|
||||
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
|
||||
test.hidden = !subscription;
|
||||
status.textContent = subscription
|
||||
? "Reminders are on for this device."
|
||||
: "Reminders are off for this device.";
|
||||
}
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => registration.pushManager.getSubscription())
|
||||
.then((existing) => {
|
||||
subscription = existing;
|
||||
render();
|
||||
});
|
||||
|
||||
toggle.addEventListener("click", async () => {
|
||||
toggle.disabled = true;
|
||||
try {
|
||||
if (subscription) {
|
||||
await post("/api/push/unsubscribe", { endpoint: subscription.endpoint });
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
} else {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return render();
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeKey(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
await post("/api/push/subscribe", subscription.toJSON());
|
||||
}
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
toggle.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
test.addEventListener("click", async () => {
|
||||
test.disabled = true;
|
||||
status.textContent = "Sending…";
|
||||
try {
|
||||
const result = await post("/api/push/test");
|
||||
status.textContent = result.detail;
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
test.disabled = false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user