feat: add PWA support with service worker and install prompt

This commit is contained in:
Linus Rath
2026-04-03 04:39:19 +02:00
parent 081c865018
commit 5aad97d64e
6 changed files with 255 additions and 0 deletions
+21
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css";
const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
};
}
@@ -36,6 +47,14 @@ export default async function RootLayout({
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install Bulwark
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Install
</button>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
}, []);
return null;
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "Bulwark Webmail",
"short_name": "Bulwark",
"description": "A modern webmail client built for Stalwart Mail Server",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["productivity"],
"screenshots": [
{
"src": "/screenshot-540x720.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/screenshot-1280x720.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
/* eslint-disable no-undef */
const CACHE_NAME = "bulwark-v1";
const STATIC_ASSETS = ["/", "/manifest.json"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
);
})
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// Skip non-GET requests
if (event.request.method !== "GET") {
return;
}
// Skip API requests and external requests
if (
event.request.url.includes("/api/") ||
!event.request.url.startsWith(self.location.origin)
) {
return;
}
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type === "error") {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});