eleven / messenger Public

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>
Paul Campbell fbff9d2c0deaea54b8f16631c4e9466781879a44 parent 51a6702
4 files changed, +202 −70
  • web/pages/index.html +28 −0
  • web/static/chat.js +102 −9
  • web/static/push.js +57 −61
  • web/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 &amp; 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); }