Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"apps/roppoh": {
"entry": [],
"ignore": ["app/shadcn/**"],
"ignore": ["app/shadcn/**", "app/sw.ts"],
"ignoreDependencies": [
"tailwindcss-animate",
"tw-animate-css",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { onMessage } from "firebase/messaging";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
getFirebaseMessaging,
isFirebaseConfigured,
} from "@/libs/firebase/config";
import { SONNER_ID_SET } from "@/libs/sonner/id";

/**
* Hook to manage browser notification permissions and Firebase Cloud Messaging.
*
* This hook:
* - Automatically requests notification permission from the user if not already granted
* - Initializes Firebase Cloud Messaging if configured
* - Retrieves and displays the FCM token for manual notification sending
* - Sets up handlers for foreground messages
*
*/
export const useNotificationPermission = () => {
const [token, _setToken] = useState<string | null>(null);
const [isInitializing, _setIsInitializing] = useState(false);

const requestPermission = useCallback(async () => {
const result = await Notification.requestPermission();
if (result !== "granted") throw new Error();

// // Initialize Firebase Cloud Messaging if configured
// if (isFirebaseConfigured()) {
// try {
// setIsInitializing(true);
// const messaging = getFirebaseMessaging();
// const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY;

// if (!vapidKey) {
// console.warn(
// "VITE_FIREBASE_VAPID_KEY is not configured. FCM notifications may not work.",
// );
// return;
// }

// const fcmToken = await getToken(messaging, { vapidKey });
// setToken(fcmToken);

// // Show token in a dismissible toast for manual Firebase Console sending
// toast.success("Notifications enabled! Copy token from console", {
// action: {
// label: "Copy",
// onClick: () => {
// navigator.clipboard.writeText(fcmToken);
// toast.success("Token copied to clipboard!");
// },
// },
// description: `Token (also in console): ${fcmToken.substring(0, 30)}...`,
// duration: 10000,
// });
// } catch (error) {
// console.error("Failed to initialize FCM:", error);
// toast.error("Failed to set up push notifications", {
// description: "Check the console for more details.",
// });
// } finally {
// setIsInitializing(false);
// }
// }
}, []);

// Handle foreground messages from FCM
useEffect(() => {
if (
!isFirebaseConfigured() ||
Notification.permission !== "granted" ||
isInitializing
) {
return undefined;
}

try {
const messaging = getFirebaseMessaging();
const unsubscribe = onMessage(messaging, (payload) => {
const notificationTitle =
payload.notification?.title || "New Notification";
const notificationOptions: NotificationOptions = {
badge: "/icons/tsar-192x192.png",
body: payload.notification?.body || "",
icon: payload.notification?.icon || "/icons/tsar-192x192.png",
tag: "fcm-notification",
};

// Show notification in foreground
new Notification(notificationTitle, notificationOptions);
});

return () => unsubscribe();
} catch (error) {
console.error("Failed to set up foreground message handler:", error);
return undefined;
}
}, [isInitializing]);

const promptNotificationPermission = useCallback(() => {
const onclick = () =>
toast.promise(() => requestPermission(), {
error: "Failed to enable notifications 😔",
loading: "Enabling notifications... ⏳",
success: () => "You're all set! 🎉",
});

toast("Stay in the loop with Roppoh 🚀", {
action: {
label: "Enable",
onClick: onclick,
},
description: "Get notified when servers start and stop ⚡",
id: SONNER_ID_SET.REQUEST_NOTIFICATION_PERMISSION,
});
}, [requestPermission]);

useEffect(() => {
if (Notification.permission === "granted") return;

promptNotificationPermission();
}, [promptNotificationPermission]);

return {
fcmToken: token,
isInitializing,
permission: Notification.permission,
};
};
Original file line number Diff line number Diff line change
@@ -1,25 +1,6 @@
import { useRegisterSW } from "virtual:pwa-register/react";
import { toast } from "sonner";

const SW_TOAST_ID = "sw-update-prompt-toast" as const;

const onNeedRefresh = (updateServiceWorker: () => Promise<void>) => {
const onClick = () =>
toast.promise(() => updateServiceWorker(), {
error: "Failed update",
loading: "Updating...",
success: () => "Update complete.",
});

toast("Now available new version", {
action: {
label: "update",
onClick: onClick,
},
description: "To use the latest service, an update is required.",
id: SW_TOAST_ID,
});
};
import { SONNER_ID_SET } from "@/libs/sonner/id";

/**
* Hook to manage PWA service worker update prompts
Expand All @@ -28,6 +9,24 @@ const onNeedRefresh = (updateServiceWorker: () => Promise<void>) => {
* is available. Users can trigger the update directly from the toast action button.
*/
export function useSwUpdatePrompt() {
const onNeedRefresh = (updateServiceWorker: () => Promise<void>) => {
const onClick = () =>
toast.promise(() => updateServiceWorker(), {
error: "Failed update",
loading: "Updating...",
success: () => "Update complete.",
});

toast("Now available new version", {
action: {
label: "update",
onClick: onClick,
},
description: "To use the latest service, an update is required.",
id: SONNER_ID_SET.SW_UPDATE,
});
};

const { updateServiceWorker } = useRegisterSW({
immediate: true,
onNeedRefresh() {
Expand Down
2 changes: 2 additions & 0 deletions apps/roppoh/app/layouts/client-side-root/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Outlet } from "react-router";
import type { Route } from "./+types/layout";
import { useMetaThemeColorSync } from "./hooks/use-meta-theme-color-sync";
import { useNotificationPermission } from "./hooks/use-notification-permission";
import { useSwUpdatePrompt } from "./hooks/use-sw-update-prompt";

// for client side render
Expand All @@ -16,6 +17,7 @@ export async function clientLoader({}: Route.ClientLoaderArgs) {}
export default function () {
const {} = useSwUpdatePrompt();
const {} = useMetaThemeColorSync();
const {} = useNotificationPermission();

return <Outlet />;
}
37 changes: 37 additions & 0 deletions apps/roppoh/app/libs/firebase/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { initializeApp } from "firebase/app";
import type { Messaging } from "firebase/messaging";
import { getMessaging } from "firebase/messaging";

const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
};

const firebaseApp = initializeApp(firebaseConfig);

let messagingInstance: Messaging | null = null;

/**
* Get Firebase Messaging instance (singleton)
* Initializes Firebase Cloud Messaging for push notifications
*/
export function getFirebaseMessaging(): Messaging {
if (!messagingInstance) {
messagingInstance = getMessaging(firebaseApp);
}
return messagingInstance;
}

/**
* Check if Firebase configuration is valid
*/
export function isFirebaseConfigured(): boolean {
return (
!!firebaseConfig.apiKey &&
!!firebaseConfig.projectId &&
!!firebaseConfig.messagingSenderId &&
!!firebaseConfig.appId
);
}
8 changes: 8 additions & 0 deletions apps/roppoh/app/libs/sonner/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Constants for consolidating and managing toast IDs with sonner.
* By specifying an ID, you can remove toasts and prevent duplicates.
*/
export const SONNER_ID_SET = {
REQUEST_NOTIFICATION_PERMISSION: "request-notification-permission",
SW_UPDATE: "sw-update",
} as const;
89 changes: 89 additions & 0 deletions apps/roppoh/app/sw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { initializeApp } from "firebase/app";
import { getMessaging, onBackgroundMessage } from "firebase/messaging/sw";
import { precacheAndRoute } from "workbox-precaching";

declare const self: ServiceWorkerGlobalScope;

// Workbox precaching - required for vite-plugin-pwa
precacheAndRoute(self.__WB_MANIFEST ?? []);

// Handle offline navigation
const FALLBACK_URL = "/offline.html";

self.addEventListener("fetch", (event: FetchEvent) => {
const { request } = event;

// Only handle navigation requests
if (request.mode !== "navigate") {
return;
}

event.respondWith(
fetch(request).catch(async () => {
const fallback = await caches.match(FALLBACK_URL);
return fallback || new Response("Offline");
}),
);
});

// Firebase initialization
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
};

let messaging: ReturnType<typeof getMessaging> | null = null;

try {
const app = initializeApp(firebaseConfig);
messaging = getMessaging(app);

// Handle background messages from FCM
onBackgroundMessage(messaging, (payload) => {
const notificationTitle = payload.notification?.title || "New Notification";
const notificationOptions: NotificationOptions = {
badge: "/icons/tsar-192x192.png",
body: payload.notification?.body || "",
icon: payload.notification?.icon || "/icons/tsar-192x192.png",
requireInteraction: false,
tag: "fcm-notification",
};

self.registration.showNotification(notificationTitle, notificationOptions);
});
} catch (error) {
console.error("Failed to initialize Firebase in Service Worker:", error);
}

// Handle notification clicks
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();

// Open the app or navigate to a specific page
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
includeUncontrolled: true,
type: "window",
});
for (const client of clientList) {
if (client.url === "/" && "focus" in client) {
client.focus();
return;
}
}
if (self.clients.openWindow) {
await self.clients.openWindow("/");
}
})(),
);
});

// Skip waiting for new SW versions
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
1 change: 1 addition & 0 deletions apps/roppoh/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dokploy-sdk": "^0.2.0",
"firebase": "^12.5.0",
"framer-motion": "^12.23.22",
"isbot": "^5.1.31",
"lucide-react": "^0.545.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/roppoh/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "esnext"],
"lib": ["DOM", "DOM.Iterable", "esnext", "WebWorker"],
"module": "esnext",
"moduleResolution": "Bundler",
"noEmit": true,
Expand Down
5 changes: 3 additions & 2 deletions apps/roppoh/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default defineConfig({
VitePWA({
base: "/",
devOptions: { enabled: false },
filename: "sw.ts",
includeAssets: [],
manifest: {
background_color: "#000000",
Expand All @@ -36,9 +37,9 @@ export default defineConfig({
outDir: "build/client",
registerType: "autoUpdate",
scope: "/",
strategies: "generateSW",
srcDir: "app",
strategies: "injectManifest",
workbox: {
globDirectory: "build/client",
globPatterns: ["**/*.{css,svg,js}"],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
navigateFallback: "offline.html",
Expand Down
Loading
Loading