Full-screen onboarding for Add-to-Home-Screen and push notifications
Replaces the header hint/button approach with full-screen onboarding steps: - iOS Safari: an "Add to Home Screen" step (shown before the chat, once). - After joining: an "Enable notifications" step with Enable / Not now. push.js refactored into reusable helpers (needsHomeScreen/pushAvailable/ registerSW/subscribePush); a small header bell remains as a fallback toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 files changed,
+202
−70
web/pages/index.html+28 −0web/static/chat.js+102 −9web/static/push.js+57 −61web/static/style.css+15 −0
diff --git a/web/pages/index.html b/web/pages/index.html| index 5f2a351..faa6c69 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -48,4 +48,32 @@ |
| </div> |
| </template> |
| +<template id="a2hs-tpl"> |
| + <div class="center"> |
| + <div class="card onboard"> |
| + <div class="onboard-icon">π²</div> |
| + <h1>Add lchat to your Home Screen</h1> |
| + <p>On iPhone & iPad, add lchat to your Home Screen so it opens like an app β and can send you notifications.</p> |
| + <ol class="steps"> |
| + <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li> |
| + <li>Scroll down and choose <strong>Add to Home Screen</strong>.</li> |
| + <li>Open <strong>lchat</strong> from your Home Screen and join there.</li> |
| + </ol> |
| + <div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div> |
| + </div> |
| + </div> |
| +</template> |
| + |
| +<template id="push-tpl"> |
| + <div class="center"> |
| + <div class="card onboard"> |
| + <div class="onboard-icon">π</div> |
| + <h1>Turn on notifications</h1> |
| + <p>Get an alert when there's a new message β even when lchat is closed.</p> |
| + <div class="row"><button id="push-enable">Enable notifications</button></div> |
| + <div class="row"><button id="push-skip" class="secondary">Not now</button></div> |
| + </div> |
| + </div> |
| +</template> |
| + |
| <script type="module" src="/static/chat.js"></script> |
diff --git a/web/static/chat.js b/web/static/chat.js| index 7119191..3131a82 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,20 +1,113 @@ |
| import { importKey, encrypt, decrypt } from "/static/crypto.js"; |
| -import { initPush } from "/static/push.js"; |
| +import { |
| + needsHomeScreen, |
| + pushAvailable, |
| + pushEnabled, |
| + pushDenied, |
| + registerSW, |
| + subscribePush, |
| +} from "/static/push.js"; |
| const SESSION_KEY = "lchat_session"; |
| const app = document.getElementById("app"); |
| const session = loadSession(); |
| -if (!session) { |
| - showNoInvite(); |
| -} else { |
| - start(session).catch((err) => { |
| - console.error(err); |
| - showBanner("Something went wrong loading the chat.", true); |
| +route(); |
| + |
| +// Full-screen onboarding steps come before the chat feed: "Add to Home Screen" |
| +// (iOS Safari) first, then "Enable notifications" once you've joined. |
| +function route() { |
| + if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") { |
| + showAddToHomeScreen(afterA2HS); |
| + } else { |
| + afterA2HS(); |
| + } |
| +} |
| + |
| +function afterA2HS() { |
| + if (!session) { |
| + showNoInvite(); |
| + } else { |
| + boot(session).catch((err) => { |
| + console.error(err); |
| + showBanner("Something went wrong loading the chat.", true); |
| + }); |
| + } |
| +} |
| + |
| +async function boot(session) { |
| + await registerSW(session); |
| + if ( |
| + pushAvailable() && |
| + Notification.permission === "default" && |
| + localStorage.getItem("lchat_push") !== "done" |
| + ) { |
| + await onboardPush(session); |
| + } |
| + start(session); |
| +} |
| + |
| +// Full-screen "Add to Home Screen" onboarding step (iOS Safari only). |
| +function showAddToHomeScreen(done) { |
| + app.innerHTML = ""; |
| + app.appendChild(tpl("a2hs-tpl")); |
| + document.getElementById("a2hs-continue").onclick = () => { |
| + localStorage.setItem("lchat_a2hs", "done"); |
| + app.innerHTML = ""; |
| + done(); |
| + }; |
| +} |
| + |
| +// Full-screen "Enable notifications" onboarding step (after joining). |
| +function onboardPush(session) { |
| + return new Promise((resolve) => { |
| + app.innerHTML = ""; |
| + app.appendChild(tpl("push-tpl")); |
| + const finish = () => { |
| + localStorage.setItem("lchat_push", "done"); |
| + app.innerHTML = ""; |
| + resolve(); |
| + }; |
| + const enable = document.getElementById("push-enable"); |
| + enable.onclick = async () => { |
| + enable.disabled = true; |
| + enable.textContent = "Enablingβ¦"; |
| + await subscribePush(session); |
| + finish(); |
| + }; |
| + document.getElementById("push-skip").onclick = finish; |
| }); |
| } |
| +// Small header control so anyone who skipped onboarding can still toggle push. |
| +function updateNotifButton(session) { |
| + const btn = document.getElementById("notif"); |
| + if (!btn) return; |
| + if (pushEnabled()) { |
| + btn.classList.remove("hidden"); |
| + btn.textContent = "π On"; |
| + btn.onclick = null; |
| + } else if (pushDenied()) { |
| + btn.classList.remove("hidden"); |
| + btn.textContent = "π Blocked"; |
| + btn.title = "Notifications are blocked β turn them on in your browser/OS settings"; |
| + btn.onclick = null; |
| + } else if (pushAvailable()) { |
| + btn.classList.remove("hidden"); |
| + btn.textContent = "π Notify me"; |
| + btn.onclick = async () => { |
| + btn.disabled = true; |
| + btn.textContent = "β¦"; |
| + await subscribePush(session); |
| + btn.disabled = false; |
| + updateNotifButton(session); |
| + }; |
| + } else { |
| + btn.classList.add("hidden"); |
| + } |
| +} |
| + |
| // No session (e.g. opened directly, or an installed iOS PWA which has its own |
| // storage). Let the user paste their invite link as text β pasted text keeps the |
| // #key fragment, which navigating to the link in an installed PWA can't. |
| @@ -91,6 +184,7 @@ function tpl(id) { |
| } |
| async function start(session) { |
| + app.innerHTML = ""; |
| app.appendChild(tpl("chat-tpl")); |
| const key = await importKey(session.key); |
| @@ -98,8 +192,7 @@ async function start(session) { |
| const who = document.querySelector(".who"); |
| who.textContent = `You are ${session.name}`; |
| - // Web Push setup (service worker + subscribe button). Best-effort. |
| - initPush(session, document.getElementById("notif")).catch((e) => console.warn("push init", e)); |
| + updateNotifButton(session); |
| document.getElementById("leave").onclick = () => { |
| if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
diff --git a/web/static/push.js b/web/static/push.js| index 99110af..f01109b 100644 |
| --- a/web/static/push.js |
| +++ b/web/static/push.js |
| @@ -1,5 +1,4 @@ |
| -// Client-side Web Push: registers the service worker, stashes the group key in |
| -// IndexedDB (so the SW can decrypt push payloads), and runs the subscribe flow. |
| +// Web Push helpers, driven by the chat page's onboarding flow (see chat.js). |
| function idbSet(key, value) { |
| return new Promise((resolve, reject) => { |
| @@ -29,66 +28,81 @@ const isStandalone = |
| window.matchMedia("(display-mode: standalone)").matches || |
| window.navigator.standalone === true; |
| -export async function initPush(session, btn) { |
| - if (!("serviceWorker" in navigator)) return; |
| +// iOS Safari can't do web push until the app is added to the Home Screen. |
| +export function needsHomeScreen() { |
| + return isiOS && !isStandalone; |
| +} |
| + |
| +export function pushSupported() { |
| + return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; |
| +} |
| + |
| +// Push is usable here and now (not "iOS, but needs install first"). |
| +export function pushAvailable() { |
| + return pushSupported() && !needsHomeScreen(); |
| +} |
| - // The service worker decrypts push payloads with this. |
| +export function pushEnabled() { |
| + return pushSupported() && Notification.permission === "granted"; |
| +} |
| + |
| +export function pushDenied() { |
| + return "Notification" in window && Notification.permission === "denied"; |
| +} |
| + |
| +let swReg = null; |
| + |
| +// Register the service worker and hand it the group key (in IndexedDB) so it can |
| +// decrypt push payloads. Best-effort; keeps any existing subscription in sync. |
| +export async function registerSW(session) { |
| + if (!("serviceWorker" in navigator)) return; |
| try { |
| await idbSet("groupKey", session.key); |
| } catch (e) { |
| console.warn("idb set failed", e); |
| } |
| - |
| - // iOS only delivers web push to installed (home-screen) PWAs β and a plain |
| - // Safari tab doesn't even expose PushManager, so check this BEFORE that guard. |
| - if (isiOS && !isStandalone) { |
| - setStatus(btn, "ios"); |
| - return; |
| - } |
| - if (!("PushManager" in window) || !("Notification" in window)) return; |
| - |
| - let reg; |
| try { |
| - reg = await navigator.serviceWorker.register("/sw.js"); |
| + swReg = await navigator.serviceWorker.register("/sw.js"); |
| } catch (e) { |
| console.warn("service worker registration failed", e); |
| return; |
| } |
| - |
| - btn.onclick = async () => { |
| - if (Notification.permission === "default") { |
| - const perm = await Notification.requestPermission(); |
| - if (perm !== "granted") return setStatus(btn, "denied"); |
| - } |
| - if (Notification.permission === "granted") await subscribe(reg, session, btn); |
| - else setStatus(btn, "denied"); |
| - }; |
| - |
| - const existing = await reg.pushManager.getSubscription(); |
| - if (existing && Notification.permission === "granted") { |
| - await sendSubscription(session.token, existing); // keep server in sync |
| - setStatus(btn, "on"); |
| - } else if (Notification.permission === "granted") { |
| - await subscribe(reg, session, btn); |
| - } else if (Notification.permission === "denied") { |
| - setStatus(btn, "denied"); |
| - } else { |
| - setStatus(btn, "ask"); |
| + if (pushEnabled()) { |
| + try { |
| + const sub = await swReg.pushManager.getSubscription(); |
| + if (sub) await sendSubscription(session.token, sub); |
| + } catch {} |
| } |
| } |
| -async function subscribe(reg, session, btn) { |
| +// Request permission (if needed) and subscribe. Returns "on" | "denied" | "error". |
| +export async function subscribePush(session) { |
| + if (!pushAvailable()) return "error"; |
| + if (Notification.permission === "default") { |
| + let perm; |
| + try { |
| + perm = await Notification.requestPermission(); |
| + } catch { |
| + return "error"; |
| + } |
| + if (perm !== "granted") return "denied"; |
| + } |
| + if (Notification.permission !== "granted") return "denied"; |
| try { |
| + const reg = swReg || (await navigator.serviceWorker.register("/sw.js")); |
| const { publicKey } = await (await fetch("/api/push/vapid")).json(); |
| - const sub = await reg.pushManager.subscribe({ |
| - userVisibleOnly: true, |
| - applicationServerKey: urlB64ToUint8Array(publicKey), |
| - }); |
| + let sub = await reg.pushManager.getSubscription(); |
| + if (!sub) { |
| + sub = await reg.pushManager.subscribe({ |
| + userVisibleOnly: true, |
| + applicationServerKey: urlB64ToUint8Array(publicKey), |
| + }); |
| + } |
| await sendSubscription(session.token, sub); |
| - setStatus(btn, "on"); |
| + return "on"; |
| } catch (e) { |
| console.warn("push subscribe failed", e); |
| - setStatus(btn, "ask"); |
| + return "error"; |
| } |
| } |
| @@ -99,21 +113,3 @@ function sendSubscription(token, sub) { |
| body: JSON.stringify({ token, subscription: sub }), |
| }); |
| } |
| - |
| -function setStatus(btn, state) { |
| - if (!btn) return; |
| - btn.classList.remove("hidden"); |
| - const text = { |
| - on: "π On", |
| - ask: "π Notify me", |
| - denied: "π Blocked", |
| - ios: "π Add to Home Screen", |
| - }; |
| - btn.textContent = text[state] || ""; |
| - btn.title = |
| - state === "denied" |
| - ? "Notifications are blocked β turn them on in your browser settings" |
| - : state === "ios" |
| - ? "On iPhone/iPad, add this page to your Home Screen, then open it to enable notifications" |
| - : ""; |
| -} |
diff --git a/web/static/style.css b/web/static/style.css| index 35af640..ee8ae8e 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -182,3 +182,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| padding: 7px 12px; |
| white-space: nowrap; |
| } |
| + |
| +/* full-screen onboarding cards */ |
| +.card.onboard { text-align: center; } |
| +.card.onboard .row { justify-content: center; } |
| +.card.onboard p { margin-bottom: 14px; } |
| +.onboard-icon { font-size: 46px; line-height: 1; margin-bottom: 10px; } |
| +.steps { |
| + text-align: left; |
| + color: var(--muted); |
| + line-height: 1.6; |
| + padding-left: 20px; |
| + margin: 4px 0 18px; |
| +} |
| +.steps li { margin-bottom: 6px; } |
| +.steps strong { color: var(--ink); } |