| 1 | // g1t's service worker: browser notifications, nothing else. It caches |
| 2 | // nothing and answers no requests, so pages load exactly as without it. |
| 3 | // Pushes come from the notify service (services/notify), encrypted for |
| 4 | // this browser: { title, body, href, tag, kind, urgent, workspace, actions, |
| 5 | // card }. A notification about a chat card (a session at its cap, a draft |
| 6 | // issue) has buttons for the card's actions that need nothing typed: a |
| 7 | // link opens its page; the others are pressed here, as on the card, by |
| 8 | // posting `card_action` to the site, and the answer shows as a notification. |
| 9 | |
| 10 | self.addEventListener("install", () => self.skipWaiting()); |
| 11 | self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); |
| 12 | |
| 13 | self.addEventListener("push", (event) => { |
| 14 | let data = {}; |
| 15 | try { |
| 16 | data = event.data ? event.data.json() : {}; |
| 17 | } catch { |
| 18 | data = { title: "g1t", body: event.data ? event.data.text() : "" }; |
| 19 | } |
| 20 | const title = data.title || "g1t"; |
| 21 | const safe = (href) => (typeof href === "string" && href.startsWith("/") && !href.startsWith("//") ? href : null); |
| 22 | const actions = Array.isArray(data.actions) |
| 23 | ? data.actions |
| 24 | .filter((a) => a && typeof a.id === "string" && typeof a.label === "string") |
| 25 | .slice(0, 2) |
| 26 | .map((a) => ({ id: a.id, label: a.label, href: safe(a.href) })) |
| 27 | : []; |
| 28 | const card = |
| 29 | data.card && typeof data.card.channel_id === "string" && typeof data.card.message_id === "string" && typeof data.workspace === "string" |
| 30 | ? { workspace: data.workspace, channel_id: data.card.channel_id, message_id: data.card.message_id } |
| 31 | : null; |
| 32 | event.waitUntil( |
| 33 | self.registration.showNotification(title, { |
| 34 | body: data.body || "", |
| 35 | icon: "/icon-192.png", |
| 36 | badge: "/badge-96.png", |
| 37 | // One notification per conversation: a newer message replaces the last. |
| 38 | tag: data.tag || undefined, |
| 39 | renotify: Boolean(data.tag), |
| 40 | actions: actions.map((a) => ({ action: a.id, title: a.label })), |
| 41 | data: { href: safe(data.href) || "/", actions, card }, |
| 42 | }), |
| 43 | ); |
| 44 | }); |
| 45 | |
| 46 | /** Presses a card's action for the person, with their session, and says what happened. */ |
| 47 | async function pressCard(card, action, title) { |
| 48 | let message = `${action.label} didn't go through. Open g1t to try again.`; |
| 49 | try { |
| 50 | const response = await fetch(`/${encodeURIComponent(card.workspace)}/-/chat/api`, { |
| 51 | method: "POST", |
| 52 | credentials: "same-origin", |
| 53 | headers: { "content-type": "application/json" }, |
| 54 | body: JSON.stringify({ intent: "card_action", channel_id: card.channel_id, message_id: card.message_id, action_id: action.id, input: null }), |
| 55 | }); |
| 56 | const result = await response.json(); |
| 57 | if (result && result.ok) message = result.value.message || (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`); |
| 58 | else if (result && result.error && result.error.message) message = result.error.message; |
| 59 | } catch { |
| 60 | // Offline, or signed out: the message above says so. |
| 61 | } |
| 62 | await self.registration.showNotification(title || "g1t", { body: message, icon: "/icon-192.png", badge: "/badge-96.png", tag: `card:${card.message_id}` }); |
| 63 | } |
| 64 | |
| 65 | self.addEventListener("notificationclick", (event) => { |
| 66 | event.notification.close(); |
| 67 | const data = event.notification.data || {}; |
| 68 | const pressed = event.action && Array.isArray(data.actions) ? data.actions.find((a) => a.id === event.action) : null; |
| 69 | if (pressed && !pressed.href && data.card) { |
| 70 | event.waitUntil(pressCard(data.card, pressed, event.notification.title)); |
| 71 | return; |
| 72 | } |
| 73 | const href = (pressed && pressed.href) || data.href || "/"; |
| 74 | const target = new URL(href, self.location.origin).href; |
| 75 | event.waitUntil( |
| 76 | (async () => { |
| 77 | const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| 78 | // A tab already on g1t: brought forward and moved there. |
| 79 | const tab = open.find((client) => new URL(client.url).origin === self.location.origin); |
| 80 | if (tab) { |
| 81 | await tab.focus(); |
| 82 | if ("navigate" in tab) { |
| 83 | try { |
| 84 | await tab.navigate(target); |
| 85 | return; |
| 86 | } catch { |
| 87 | // Not ours to navigate (uncontrolled): told instead. |
| 88 | } |
| 89 | } |
| 90 | tab.postMessage({ type: "g1t:navigate", href }); |
| 91 | return; |
| 92 | } |
| 93 | await self.clients.openWindow(target); |
| 94 | })(), |
| 95 | ); |
| 96 | }); |