diff --git a/app/layout.tsx b/app/layout.tsx index 2a1c0212..f23d8a25 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 { 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 ( + + + + + {parentOrigin && ( )} @@ -63,7 +82,9 @@ export default async function RootLayout({ + {children} + ); diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index 1a32a0d2..96292dc8 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -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); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx new file mode 100644 index 00000000..727d69c8 --- /dev/null +++ b/components/pwa-install-prompt.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { X, Download } from "lucide-react"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export function PWAInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = + useState(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 ( +
+
+
+ +
+

+ Install Bulwark +

+

+ Install our app for quick access and offline support. +

+
+
+ +
+
+ + +
+
+ ); +} diff --git a/components/service-worker-registration.tsx b/components/service-worker-registration.tsx new file mode 100644 index 00000000..92724ddb --- /dev/null +++ b/components/service-worker-registration.tsx @@ -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; +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..b483c230 --- /dev/null +++ b/public/manifest.json @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..f338bd02 --- /dev/null +++ b/public/sw.js @@ -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; + }); + }) + ); +});