One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #188 from EvanTechDev/feature/add-pwa-support-and-fix-notification-bug

feat(pwa): add service worker and fix notification delivery

authored by

Evan Huang and committed by
GitHub
9d47660f e98a4a80

+149 -7
+8 -1
app/layout.tsx
··· 1 1 import type React from "react" 2 - import type { Metadata } from "next" 2 + import type { Metadata, Viewport } from "next" 3 3 import "./globals.css" 4 4 import { Toaster } from "@/components/ui/sonner" 5 5 import { CalendarProvider } from "@/components/providers/calendar-context" ··· 7 7 import { enUS } from '@clerk/localizations' 8 8 import { GeistSans } from "geist/font/sans" 9 9 import { ThemeProvider } from "@/components/providers/theme-provider" 10 + import { PwaProvider } from "@/components/providers/pwa-provider" 10 11 11 12 export const metadata: Metadata = { 12 13 title: "One Calendar", 13 14 description: "All your events in one place, beautifully organized.", 15 + manifest: "/manifest.webmanifest", 14 16 openGraph: { 15 17 title: "One Calendar", 16 18 description: "All your events in one place, beautifully organized.", ··· 40 42 }, 41 43 } 42 44 45 + 46 + export const viewport: Viewport = { 47 + themeColor: "#0b0f1a", 48 + } 43 49 export default function RootLayout({ 44 50 children, 45 51 }: { ··· 63 69 disableTransitionOnChange 64 70 > 65 71 <CalendarProvider> 72 + <PwaProvider /> 66 73 {children} 67 74 <Toaster /> 68 75 </CalendarProvider>
+25
app/manifest.ts
··· 1 + import type { MetadataRoute } from "next"; 2 + 3 + export default function manifest(): MetadataRoute.Manifest { 4 + return { 5 + name: "One Calendar", 6 + short_name: "One Calendar", 7 + description: "All your events in one place, beautifully organized.", 8 + id: "/app", 9 + start_url: "/app", 10 + scope: "/app", 11 + display: "standalone", 12 + orientation: "landscape", 13 + background_color: "#0b0f1a", 14 + theme_color: "#0b0f1a", 15 + lang: "en", 16 + icons: [ 17 + { 18 + src: "/icon.svg", 19 + sizes: "any", 20 + type: "image/svg+xml", 21 + purpose: "any", 22 + }, 23 + ], 24 + }; 25 + }
+24
components/providers/pwa-provider.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect } from "react"; 4 + 5 + export function PwaProvider() { 6 + useEffect(() => { 7 + if (!("serviceWorker" in navigator)) return; 8 + 9 + const registerServiceWorker = async () => { 10 + try { 11 + await navigator.serviceWorker.register("/sw.js", { 12 + scope: "/", 13 + updateViaCache: "none", 14 + }); 15 + } catch { 16 + // Silent fail to avoid breaking the app on unsupported browsers. 17 + } 18 + }; 19 + 20 + registerServiceWorker(); 21 + }, []); 22 + 23 + return null; 24 + }
+21 -6
lib/notifications.ts
··· 68 68 }); 69 69 }; 70 70 71 + const getServiceWorkerRegistration = async () => { 72 + if (typeof window === "undefined") return null; 73 + if (!("serviceWorker" in navigator)) return null; 74 + 75 + try { 76 + const currentRegistration = await navigator.serviceWorker.getRegistration(); 77 + if (currentRegistration) return currentRegistration; 78 + 79 + return await navigator.serviceWorker.register("/sw.js", { 80 + scope: "/", 81 + updateViaCache: "none", 82 + }); 83 + } catch { 84 + return null; 85 + } 86 + }; 87 + 71 88 const showSystemNotification = async (event: any) => { 72 89 if (typeof window === "undefined") return; 73 90 if (!("Notification" in window)) return; ··· 96 113 badge: "/favicon.ico", 97 114 }; 98 115 99 - if ("serviceWorker" in navigator) { 116 + const registration = await getServiceWorkerRegistration(); 117 + if (registration) { 100 118 try { 101 - const registration = await navigator.serviceWorker.getRegistration(); 102 - if (registration) { 103 - await registration.showNotification(title, options); 104 - return; 105 - } 119 + await registration.showNotification(title, options); 120 + return; 106 121 } catch {} 107 122 } 108 123
+71
public/sw.js
··· 1 + const CACHE_NAME = "one-calendar-shell-v1"; 2 + const OFFLINE_URLS = ["/app", "/icon.svg"]; 3 + 4 + self.addEventListener("install", (event) => { 5 + event.waitUntil( 6 + caches 7 + .open(CACHE_NAME) 8 + .then((cache) => cache.addAll(OFFLINE_URLS)) 9 + .then(() => self.skipWaiting()), 10 + ); 11 + }); 12 + 13 + self.addEventListener("activate", (event) => { 14 + event.waitUntil( 15 + caches.keys().then((keys) => 16 + Promise.all( 17 + keys 18 + .filter((key) => key !== CACHE_NAME) 19 + .map((key) => caches.delete(key)), 20 + ), 21 + ), 22 + ); 23 + 24 + self.clients.claim(); 25 + }); 26 + 27 + self.addEventListener("fetch", (event) => { 28 + if (event.request.method !== "GET") return; 29 + 30 + event.respondWith( 31 + caches.match(event.request).then((cachedResponse) => { 32 + if (cachedResponse) { 33 + return cachedResponse; 34 + } 35 + 36 + return fetch(event.request) 37 + .then((networkResponse) => { 38 + if (!networkResponse || networkResponse.status !== 200) { 39 + return networkResponse; 40 + } 41 + 42 + const responseToCache = networkResponse.clone(); 43 + caches.open(CACHE_NAME).then((cache) => { 44 + cache.put(event.request, responseToCache); 45 + }); 46 + 47 + return networkResponse; 48 + }) 49 + .catch(() => caches.match("/app")); 50 + }), 51 + ); 52 + }); 53 + 54 + self.addEventListener("notificationclick", (event) => { 55 + event.notification.close(); 56 + 57 + event.waitUntil( 58 + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { 59 + for (const client of clients) { 60 + if ("focus" in client) { 61 + client.focus(); 62 + return; 63 + } 64 + } 65 + 66 + if (self.clients.openWindow) { 67 + return self.clients.openWindow("/app"); 68 + } 69 + }), 70 + ); 71 + });