diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 0000000000..6d0bd55e61 --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,82 @@ +/* eslint-disable */ +// firebase-messaging-sw.js +importScripts( + "https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js", +); +importScripts( + "https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js", +); + +const ENV = { + LOCAL: "http://localhost:3000", + DEV: "https://web-dev.common.io", + STAGE: "https://web-staging.common.io", + PRODUCTION: "https://common.io", +}; + +const FIREBASE_CONFIG_ENV = { + DEV: { + apiKey: "AIzaSyDbTFuksgOkIVWDiFe_HG7-BE8X6Dwsg-0", + authDomain: "common-dev-34b09.firebaseapp.com", + databaseURL: "https://common-dev-34b09.firebaseio.com", + projectId: "common-dev-34b09", + storageBucket: "common-dev-34b09.appspot.com", + messagingSenderId: "870639147922", + appId: "1:870639147922:web:9ee954bb1dd52e25cb7f4b", + }, + STAGE: { + apiKey: "AIzaSyBASCWJMV64mZJObeFEitLmdUC1HqmtjJk", + authDomain: "common-staging-1d426.firebaseapp.com", + databaseURL: "https://common-staging-1d426.firebaseio.com", + projectId: "common-staging-1d426", + storageBucket: "common-staging-1d426.appspot.com", + messagingSenderId: "701579202562", + appId: "1:701579202562:web:5729d8a875f98f6709571b", + }, + PRODUCTION: { + apiKey: "AIzaSyAlYrKLd6KNKVkhmNEMKfb0cWHSWicCBOY", + authDomain: "common-production-67641.firebaseapp.com", + databaseURL: "https://common-production-67641.firebaseio.com", + projectId: "common-production-67641", + storageBucket: "common-production-67641.appspot.com", + messagingSenderId: "461029494046", + appId: "1:461029494046:web:4e2e4afbbeb7b487b48d0f", + }, +}; + +let firebaseConfig = {}; + +switch (location.origin) { + case ENV.LOCAL: + case ENV.DEV: { + firebaseConfig = FIREBASE_CONFIG_ENV.DEV; + break; + } + case ENV.STAGE: { + firebaseConfig = FIREBASE_CONFIG_ENV.STAGE; + break; + } + case ENV.PRODUCTION: { + firebaseConfig = FIREBASE_CONFIG_ENV.PRODUCTION; + break; + } + default: { + firebaseConfig = FIREBASE_CONFIG_ENV.DEV; + break; + } +} + +firebase.initializeApp(firebaseConfig); + +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage((payload) => { + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + data: payload.data, + icon: "/logo.png", + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000..94e4735b4d Binary files /dev/null and b/public/logo.png differ diff --git a/src/config.tsx b/src/config.tsx index be0bab8e2d..5975af4f05 100644 --- a/src/config.tsx +++ b/src/config.tsx @@ -27,6 +27,8 @@ export const local: Configuration = { deadSeaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", parentsForClimateCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", saadiaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", + vapidKey: + "BHVFyNetSC6oA2uFejnUFuDcSUYcas2R5lwW80z6gZc6zODp7rRdh2t8bht3LygJWjyI1toV165EYgdZqxCS_Y4", }; const dev: Configuration = { @@ -44,6 +46,8 @@ const dev: Configuration = { deadSeaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", parentsForClimateCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", saadiaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", + vapidKey: + "BHVFyNetSC6oA2uFejnUFuDcSUYcas2R5lwW80z6gZc6zODp7rRdh2t8bht3LygJWjyI1toV165EYgdZqxCS_Y4", }; const stage: Configuration = { @@ -62,6 +66,8 @@ const stage: Configuration = { deadSeaCommonId: "a55a1e9b-104a-4866-9f4f-3e017bbae281", parentsForClimateCommonId: "a55a1e9b-104a-4866-9f4f-3e017bbae281", saadiaCommonId: "a55a1e9b-104a-4866-9f4f-3e017bbae281", + vapidKey: + "BBvr8z8QaPSJJfIRxmjBrq5Vs49BY95uZK_6QFyR7gKWgwrs5toDy-hvwWEtk-rbkVHBgOu9l2orK45u1n--9M0", }; const production: Configuration = { @@ -80,6 +86,8 @@ const production: Configuration = { deadSeaCommonId: "6cfbfae6-2e5c-4b3b-ba70-e8fd871f48e2", parentsForClimateCommonId: "04ac2ec2-5cb2-4ab9-ae3f-5f223f482768", saadiaCommonId: "7c8c8996-b678-44df-9a57-e291431eb00f", + vapidKey: + "BKJ324iR-B5SoDG42bMrC_Q_poAv7BO-Z3AuMh5Grrg6TxO1QnN6mgzt2KyFFax0JSuuUhUKP-OrcTUPfboVqns", }; const config: ConfigurationObject = { diff --git a/src/pages/App/App.tsx b/src/pages/App/App.tsx index 4ffe2e826d..5e0c242064 100644 --- a/src/pages/App/App.tsx +++ b/src/pages/App/App.tsx @@ -13,6 +13,7 @@ import { ThemeHandler, UserNotificationsAmountHandler, WebViewLoginHandler, + NotificationsHandler, } from "./handlers"; import { Router } from "./router"; @@ -34,6 +35,7 @@ const App = () => { + diff --git a/src/pages/App/handlers/NotificationsHandler/NotificationsHandler.tsx b/src/pages/App/handlers/NotificationsHandler/NotificationsHandler.tsx new file mode 100644 index 0000000000..44f26f4f59 --- /dev/null +++ b/src/pages/App/handlers/NotificationsHandler/NotificationsHandler.tsx @@ -0,0 +1,71 @@ +import { FC, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { selectUser } from "@/pages/Auth/store/selectors"; +import { NotificationService } from "@/services"; + +const NotificationsHandler: FC = () => { + const user = useSelector(selectUser()); + const userId = user?.uid; + const [isRegistered, setIsRegistered] = useState(false); + + function initServiceWorker() { + navigator.serviceWorker + .register("/firebase-messaging-sw.js") + .then((registration) => { + setIsRegistered(true); + return registration; + }) + .catch((err) => { + console.log("ServiceWorker registration failed: ", err); + }); + } + + // Check if the service worker is already registered or register a new one + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .getRegistration("/firebase-messaging-sw.js") + .then((existingRegistration) => { + if (existingRegistration) { + setIsRegistered(true); + } else { + initServiceWorker(); + } + + return; + }) + .catch((err) => { + console.log("Error checking service worker registration: ", err); + }); + } + }, []); + + // Handle notification permissions and foreground message listener + useEffect(() => { + if (!userId || !isRegistered) { + return; + } + + let unsubscribeOnMessage; + (async () => { + const hasPermissions = await NotificationService.requestPermissions(); + if (!hasPermissions) { + console.log("Notification permissions denied"); + return; + } + + await NotificationService.saveFCMToken(); + unsubscribeOnMessage = NotificationService.onForegroundMessage(); + })(); + + return () => { + if (unsubscribeOnMessage) { + unsubscribeOnMessage(); + } + }; + }, [userId, isRegistered]); + + return null; +}; + +export default NotificationsHandler; diff --git a/src/pages/App/handlers/NotificationsHandler/index.ts b/src/pages/App/handlers/NotificationsHandler/index.ts new file mode 100644 index 0000000000..c7c53200f0 --- /dev/null +++ b/src/pages/App/handlers/NotificationsHandler/index.ts @@ -0,0 +1 @@ +export { default as NotificationsHandler } from "./NotificationsHandler"; \ No newline at end of file diff --git a/src/pages/App/handlers/index.ts b/src/pages/App/handlers/index.ts index 712e7a4d41..37e8eb0fb0 100644 --- a/src/pages/App/handlers/index.ts +++ b/src/pages/App/handlers/index.ts @@ -3,3 +3,4 @@ export * from "./TextDirectionHandler"; export * from "./UserNotificationsAmountHandler"; export * from "./WebViewLoginHandler"; export * from "./ThemeHandler"; +export * from "./NotificationsHandler"; diff --git a/src/services/Notification.ts b/src/services/Notification.ts new file mode 100644 index 0000000000..25e7597b3c --- /dev/null +++ b/src/services/Notification.ts @@ -0,0 +1,73 @@ +import firebase from "@/shared/utils/firebase"; +import firebaseConfig from "@/config"; +import Api from "./Api"; + +enum NOTIFICATIONS_PERMISSIONS { + DEFAULT = "default", + DENIED = "denied", + GRANTED = "granted" +} + + +class NotificationService { + private endpoints: { + setFCMToken: string; + }; + + constructor() { + this.endpoints = { + setFCMToken: '/users/auth/google/set-fcm-token', + }; + } + + public requestPermissions = async (): Promise => { + try { + if(Notification.permission === NOTIFICATIONS_PERMISSIONS.GRANTED) { + return true; + } + const permission = await Notification.requestPermission(); + if (permission === NOTIFICATIONS_PERMISSIONS.GRANTED) { + return true; + } else { + return false; + } + } catch (err) { + return false; + } + } + + public saveFCMToken = async (): Promise => { + try { + const token = await firebase.messaging().getToken({ vapidKey: firebaseConfig.vapidKey }); + if (token) { + + await Api.post( + this.endpoints.setFCMToken, + { + token, + } + ); + } + } catch (error) { + console.error("An error occurred while retrieving token. ", error); + } + } + + public onForegroundMessage = () => { + const unsubscribe = firebase.messaging().onMessage((payload) => { + + const { title, body } = payload.notification; + if (Notification.permission === 'granted') { + new Notification(title, { + body, + data: payload?.data, + icon: "/logo.png", + }); + } + }); + + return unsubscribe; + } +} + +export default new NotificationService(); diff --git a/src/services/index.ts b/src/services/index.ts index 03d9bf706f..bf6d10f1ef 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -25,3 +25,4 @@ export { } from "./DiscussionMessage"; export { default as NotionService } from "./Notion"; export { default as FeatureFlagService } from "./FeatureFlag"; +export { default as NotificationService } from "./Notification"; diff --git a/src/shared/components/Header/Header.tsx b/src/shared/components/Header/Header.tsx index 672bd3bff2..8d10f043e0 100755 --- a/src/shared/components/Header/Header.tsx +++ b/src/shared/components/Header/Header.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from "react-redux"; import { Link, RouteProps, useHistory } from "react-router-dom"; import classNames from "classnames"; import { Routes } from "@/pages/MyAccount/components/Routes"; +import { NotificationService } from "@/services"; import { Loader } from "@/shared/components"; import { useAnyMandatoryRoles, @@ -82,7 +83,8 @@ const Header = () => { setShowAccountLinks(isMyAccountRoute); }, [showMenu, isMyAccountRoute]); - const handleLogIn = useCallback(() => { + const handleLogIn = useCallback(async () => { + await NotificationService.requestPermissions(); dispatch(setLoginModalState({ isShowing: true })); setShowMenu(false); }, [dispatch]); @@ -116,7 +118,8 @@ const Header = () => { dispatch(logOut()); }; - const handleLaunchApp = () => { + const handleLaunchApp = async () => { + await NotificationService.requestPermissions(); history.push(ROUTE_PATHS.INBOX); }; diff --git a/src/shared/constants/featureFlags.ts b/src/shared/constants/featureFlags.ts index 9efdb4f72c..429aa64a56 100644 --- a/src/shared/constants/featureFlags.ts +++ b/src/shared/constants/featureFlags.ts @@ -3,6 +3,7 @@ export enum FeatureFlags { AiBot = "AiBot", AiBotPro = "AiBotPro", UpdateRoles = "UpdateRoles", + HavingAnIssue = "HavingAnIssue" } export enum FeatureFlagVisibility { diff --git a/src/shared/interfaces/Configuration.tsx b/src/shared/interfaces/Configuration.tsx index 94c71bcfce..73f6e96ef6 100644 --- a/src/shared/interfaces/Configuration.tsx +++ b/src/shared/interfaces/Configuration.tsx @@ -15,6 +15,7 @@ export interface Configuration { deadSeaCommonId: string; parentsForClimateCommonId: string; saadiaCommonId: string; + vapidKey: string; } export type ConfigurationObject = Record; diff --git a/src/shared/layouts/CommonSidenavLayout/CommonSidenavLayout.module.scss b/src/shared/layouts/CommonSidenavLayout/CommonSidenavLayout.module.scss index 23b4c9cdf1..c5b7f52a08 100644 --- a/src/shared/layouts/CommonSidenavLayout/CommonSidenavLayout.module.scss +++ b/src/shared/layouts/CommonSidenavLayout/CommonSidenavLayout.module.scss @@ -5,7 +5,7 @@ --main-mw: 100%; --main-pl: unset; --sb-max-width: unset; - --sb-width: 21rem; + --sb-width: 18.75rem; --sb-content-max-width: 100%; --sb-content-width: 100%; --sb-content-pb: 0; diff --git a/src/shared/layouts/MultipleSpacesLayout/MultipleSpacesLayout.module.scss b/src/shared/layouts/MultipleSpacesLayout/MultipleSpacesLayout.module.scss index 1f259c1b07..edbb5f0e99 100644 --- a/src/shared/layouts/MultipleSpacesLayout/MultipleSpacesLayout.module.scss +++ b/src/shared/layouts/MultipleSpacesLayout/MultipleSpacesLayout.module.scss @@ -5,7 +5,7 @@ --main-mw: calc(120rem + var(--sb-h-indent, 0)); --main-pl: calc(var(--sb-h-indent, 0)); --sb-max-width: unset; - --sb-width: 21rem; + --sb-width: 18.75rem; --sb-content-max-width: 100%; --sb-content-width: 100%; --sb-content-pb: 0; diff --git a/src/shared/layouts/SidenavLayout/SidenavLayout.module.scss b/src/shared/layouts/SidenavLayout/SidenavLayout.module.scss index 014271ce7f..3770f017f7 100644 --- a/src/shared/layouts/SidenavLayout/SidenavLayout.module.scss +++ b/src/shared/layouts/SidenavLayout/SidenavLayout.module.scss @@ -4,7 +4,7 @@ .container { --main-pl: unset; --sb-max-width: unset; - --sb-width: 18.75rem; + --sb-width: 15rem; --sb-content-max-width: 100%; --sb-content-width: 100%; --sb-content-pb: 0; diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/UserInfo/components/MenuItems/MenuItems.tsx b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/UserInfo/components/MenuItems/MenuItems.tsx index 825d64116a..e86ada241f 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/UserInfo/components/MenuItems/MenuItems.tsx +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/UserInfo/components/MenuItems/MenuItems.tsx @@ -1,18 +1,22 @@ -import React, { FC } from "react"; +import React, { FC, useMemo } from "react"; import { useDispatch } from "react-redux"; import { useLocation } from "react-router"; import classNames from "classnames"; import { Menu } from "@headlessui/react"; import { logOut } from "@/pages/Auth/store/actions"; +import { FeatureFlags } from "@/shared/constants"; import { useRoutesContext } from "@/shared/contexts"; +import { useFeatureFlag } from "@/shared/hooks/useFeatureFlag"; import { Avatar3Icon, BillingIcon, LogoutIcon, NotificationsIcon, } from "@/shared/icons"; +import ReportIcon from "@/shared/icons/report.icon"; import ThemeIcon from "@/shared/icons/theme.icon"; import { toggleTheme } from "@/shared/store/actions"; +import { clearFirestoreCache } from "@/shared/utils/firebase"; import { MenuItem } from "./components"; import { Item, ItemType } from "./types"; import styles from "./MenuItems.module.scss"; @@ -43,6 +47,9 @@ const MenuItems: FC = (props) => { const { pathname } = useLocation(); const isV04 = pathname.includes("-v04"); + const featureFlags = useFeatureFlag(); + const isHavingAnIssueEnabled = featureFlags?.get(FeatureFlags.HavingAnIssue); + const toggleThemeMenuItem = { key: "theme", type: ItemType.Button, @@ -53,36 +60,53 @@ const MenuItems: FC = (props) => { }, }; - const items: Item[] = [ - { - key: "my-profile", - text: "My profile", - icon: , - to: getProfilePagePath(), - }, - { - key: "settings", - text: "Notifications", - icon: , - to: getSettingsPagePath(), - }, - { - key: "billing", - text: "Billing", - icon: , - to: getBillingPagePath(), - }, - ...insertIf(!isV04, toggleThemeMenuItem), - { - key: "log-out", - type: ItemType.Button, - text: "Log out", - icon: , - onClick: () => { - dispatch(logOut()); + const items: Item[] = useMemo(() => { + const menuItems = [ + { + key: "my-profile", + text: "My profile", + icon: , + type: ItemType.Button, + to: getProfilePagePath(), }, - }, - ]; + { + key: "settings", + text: "Notifications", + icon: , + to: getSettingsPagePath(), + }, + { + key: "billing", + text: "Billing", + icon: , + to: getBillingPagePath(), + }, + ...insertIf(!isV04, toggleThemeMenuItem), + { + key: "log-out", + type: ItemType.Button, + text: "Log out", + icon: , + onClick: () => { + dispatch(logOut()); + }, + }, + ]; + + if (isHavingAnIssueEnabled) { + menuItems.push({ + key: "issue", + text: "Having an issue?", + icon: , + type: ItemType.Button, + onClick: () => { + clearFirestoreCache(); + }, + }); + } + + return menuItems; + }, [isHavingAnIssueEnabled, isV04, toggleThemeMenuItem]); return ( diff --git a/src/shared/utils/firebase.tsx b/src/shared/utils/firebase.tsx index 3db4544c9a..6755110688 100644 --- a/src/shared/utils/firebase.tsx +++ b/src/shared/utils/firebase.tsx @@ -1,6 +1,7 @@ import firebase from "firebase/compat/app"; import "firebase/compat/auth"; import "firebase/compat/firestore"; +import "firebase/compat/messaging"; import "firebase/compat/performance"; import "firebase/compat/storage"; import { getPerformance } from "firebase/performance"; @@ -8,12 +9,81 @@ import { local } from "@/config"; import { Environment, REACT_APP_ENV } from "@/shared/constants"; import config from "../../config"; +const CACHE_SIZE_LIMIT = 104857600; // 100 MB + interface FirebaseError extends Error { code: string; } const app = firebase.initializeApp(config.firebase); +let db = firebase.firestore(); + +enableUnlimitedCachePersistence(); +// Function to handle Firestore persistence errors +function handlePersistenceError(err: any) { + if (err.code === "failed-precondition") { + console.log("Multiple tabs open or other conflict."); + } else if (err.code === "unimplemented") { + console.log("Persistence is not supported in this browser."); + } else if (err.name === "QuotaExceededError") { + console.log("Storage quota exceeded. Consider clearing cache."); + clearFirestoreCache(); + } else { + console.error("Error enabling persistence:", err); + reinitializeFirestoreWithPersistence(); + } +} + +function reinitializeFirestoreWithPersistence() { + db = firebase.firestore(); // Reinitialize Firestore instance + const settings = { cacheSizeBytes: CACHE_SIZE_LIMIT }; + db.settings(settings); + + db.enablePersistence({ synchronizeTabs: true }) + .then(() => { + console.log("Persistence re-enabled."); + return; + }) + .catch(handlePersistenceError); +} + +// Function to clear Firestore cache and re-enable persistence +export function clearFirestoreCache() { + db.terminate() + .then(() => { + console.log("Firestore instance terminated."); + return db.clearPersistence(); // Safe to clear persistence now + }) + .then(() => { + console.log("Persistence cleared. Waiting before reinitializing..."); + return new Promise((resolve) => setTimeout(resolve, 2000)); // Wait 2 second + }) + .then(() => { + console.log("Cache cleared successfully."); + reinitializeFirestoreWithPersistence(); // Reinitialize Firestore + window.location.reload(); + return; + }) + .catch((err) => { + if (err.code === "failed-precondition") { + console.log("Cannot clear persistence: Firestore is still running."); + } else { + console.error("Error clearing persistence cache:", err); + } + }); +} + +// Enable Firestore persistence with unlimited cache size and error handling +function enableUnlimitedCachePersistence() { + const settings = { + cacheSizeBytes: CACHE_SIZE_LIMIT, + }; + db.settings(settings); + + db.enablePersistence({ synchronizeTabs: true }).catch(handlePersistenceError); +} +// Enable persistence in the local environment (with Firestore and Auth emulators) if (REACT_APP_ENV === Environment.Local) { firebase.auth().useEmulator(local.firebase.authDomain); firebase @@ -22,16 +92,6 @@ if (REACT_APP_ENV === Environment.Local) { "localhost", Number(local.firebase.databaseURL.split(/:/g)[2]), ); -} else { - firebase - .firestore() - .enablePersistence({ - synchronizeTabs: true, - experimentalForceOwningTab: false, - }) - .catch((error) => { - console.error("Error enabling persistence", error); - }); } let perf; diff --git a/src/shared/utils/tests/mockConfig.ts b/src/shared/utils/tests/mockConfig.ts index 502ef3bcfd..8f9410f5a9 100644 --- a/src/shared/utils/tests/mockConfig.ts +++ b/src/shared/utils/tests/mockConfig.ts @@ -18,5 +18,6 @@ jest.mock( deadSeaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", parentsForClimateCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", saadiaCommonId: "958dca85-7bc1-4714-95bd-1fc6343f0654", + vapidKey: "VAPID_KEY", }) );