fix: consolidate PWA banners — install in browser, update in PWA only

Remove duplicate purple update banner from website/shell.ts that overlapped
with the server/shell.ts banner. Now a single banner system:
- Browser: "Install rSpace app" prompt (via beforeinstallprompt)
- Installed PWA: "New version available" prompt (via SW update detection)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 12:09:59 -07:00
parent 26c07a7672
commit bb19b4bc89
2 changed files with 37 additions and 114 deletions

View File

@ -350,10 +350,15 @@ export function renderShell(opts: ShellOptions): string {
<script type="module">
import '/shell.js';
// ── Service worker registration + update detection ──
// ── PWA: single consolidated banner ──
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
// Service worker registration + update detection
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js?v=5").then((reg) => {
function showUpdateBanner() {
if (!isStandalone) return; // Only show update prompt in installed PWA
const b = document.getElementById('pwa-update-banner');
if (!b || b.style.display !== 'none') return;
b.style.display = '';
@ -375,31 +380,36 @@ export function renderShell(opts: ShellOptions): string {
location.reload();
});
}
// ── PWA install banner ──
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
window.dispatchEvent(new CustomEvent("rspace-install-available"));
if (localStorage.getItem('rspace_install_dismissed') !== '1') {
const b = document.getElementById('pwa-install-banner');
if (b) { b.style.display = ''; document.body.classList.add('rspace-banner-visible'); }
}
});
document.getElementById('pwa-install-btn')?.addEventListener('click', async () => {
if (window.__rspaceInstallPrompt) {
const choice = await window.__rspaceInstallPrompt();
if (choice?.outcome === 'accepted') {
document.getElementById('pwa-install-banner').style.display = 'none';
document.body.classList.remove('rspace-banner-visible');
localStorage.setItem('rspace_install_dismissed', '1');
// Install banner — browser only (not shown in installed PWA)
if (!isStandalone) {
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
window.dispatchEvent(new CustomEvent("rspace-install-available"));
if (localStorage.getItem('rspace_install_dismissed') !== '1') {
const b = document.getElementById('pwa-install-banner');
if (b) { b.style.display = ''; document.body.classList.add('rspace-banner-visible'); }
}
}
});
document.getElementById('pwa-install-close')?.addEventListener('click', () => {
document.getElementById('pwa-install-banner').style.display = 'none';
document.body.classList.remove('rspace-banner-visible');
localStorage.setItem('rspace_install_dismissed', '1');
});
});
document.getElementById('pwa-install-btn')?.addEventListener('click', async () => {
if (window.__rspaceInstallPrompt) {
const choice = await window.__rspaceInstallPrompt();
if (choice?.outcome === 'accepted') {
document.getElementById('pwa-install-banner').style.display = 'none';
document.body.classList.remove('rspace-banner-visible');
localStorage.setItem('rspace_install_dismissed', '1');
}
}
});
document.getElementById('pwa-install-close')?.addEventListener('click', () => {
document.getElementById('pwa-install-banner').style.display = 'none';
document.body.classList.remove('rspace-banner-visible');
localStorage.setItem('rspace_install_dismissed', '1');
});
}
// Update banner button handlers (always wire, shown conditionally above)
document.getElementById('pwa-update-btn')?.addEventListener('click', () => {
navigator.serviceWorker?.getRegistration().then((reg) => { reg?.waiting?.postMessage({ type: 'SKIP_WAITING' }); });
});
@ -407,10 +417,6 @@ export function renderShell(opts: ShellOptions): string {
document.getElementById('pwa-update-banner').style.display = 'none';
document.body.classList.remove('rspace-banner-visible');
});
if (window.matchMedia('(display-mode: standalone)').matches) {
const ib = document.getElementById('pwa-install-banner');
if (ib) ib.style.display = 'none';
}
// ── Header minimize toggle ──
if (localStorage.getItem('rspace_headers_minimized') === '1') {
document.body.classList.add('rspace-headers-minimized');

View File

@ -133,88 +133,5 @@ document.addEventListener("auth-change", (e) => {
}
});
// ── SW Update Banner ──
// Show "new version available" when a new service worker activates.
// The SW calls skipWaiting() so it activates immediately — we detect the
// controller change and prompt the user to reload for the fresh content.
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
// Only listen if there's already a controller (skip first-time install)
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
showUpdateBanner();
});
}
// Also detect waiting workers (edge case: skipWaiting didn't fire yet)
navigator.serviceWorker.getRegistration().then((reg) => {
if (!reg) return;
if (reg.waiting && navigator.serviceWorker.controller) {
showUpdateBanner();
return;
}
reg.addEventListener("updatefound", () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner();
}
});
});
});
}
function showUpdateBanner() {
if (document.getElementById("sw-update-banner")) return;
const banner = document.createElement("div");
banner.id = "sw-update-banner";
banner.setAttribute("role", "alert");
banner.innerHTML = `
<span>New version available</span>
<button id="sw-update-btn">Tap to update</button>
<button id="sw-update-dismiss" aria-label="Dismiss">&times;</button>
`;
const style = document.createElement("style");
style.textContent = `
#sw-update-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 10px 16px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white; font-size: 14px; font-weight: 500;
font-family: system-ui, -apple-system, sans-serif;
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
animation: sw-slide-down 0.3s ease-out;
}
@keyframes sw-slide-down {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
#sw-update-btn {
padding: 5px 14px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.5);
background: rgba(255,255,255,0.15); color: white;
font-size: 13px; font-weight: 600; cursor: pointer;
transition: background 0.15s;
}
#sw-update-btn:hover { background: rgba(255,255,255,0.3); }
#sw-update-dismiss {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: rgba(255,255,255,0.7);
font-size: 20px; cursor: pointer; padding: 4px 8px; line-height: 1;
}
#sw-update-dismiss:hover { color: white; }
`;
document.head.appendChild(style);
document.body.prepend(banner);
banner.querySelector("#sw-update-btn")!.addEventListener("click", () => {
window.location.reload();
});
banner.querySelector("#sw-update-dismiss")!.addEventListener("click", () => {
banner.remove();
});
}
// SW update banner is handled by the inline script in server/shell.ts
// (single #pwa-update-banner element, no duplicate)