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",
})
);