From c1171ad9e9a60aff3dec6a89bce36af33ecb2a54 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 7 Oct 2025 17:02:12 +0200 Subject: [PATCH] notifications ok --- android/app/src/main/AndroidManifest.xml | 1 + app.json | 3 +- functions/src/notifications.js | 16 ++- src/components/BaseHeader.js | 16 ++- src/config/firebase.js | 1 + src/config/initialGlobalState.js | 1 + src/hooks/useNotifications.js | 6 + src/providers/NotificationProvider.js | 176 ++++++++++++++++++----- 8 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 src/hooks/useNotifications.js diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb377cd..2cb6939 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/app.json b/app.json index 62055b0..ccc2d82 100644 --- a/app.json +++ b/app.json @@ -60,7 +60,8 @@ "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.CAMERA", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.POST_NOTIFICATIONS" ] }, "web": { diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 5e80aff..216cc74 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -89,20 +89,26 @@ const sendNotification = async ({ if (!receiver || !message) { throw new Error("Receiver and message are required"); } - const { id } = await refList.notifications.add({ + const payload = { sender, receiver, receiverCollection, title, message, - time: new Date(), + time: admin.firestore.FieldValue.serverTimestamp(), + read: false, + readAt: null, data, - }); - console.log(`Notification ${id} created`); + }; + const { id } = await refList.notifications.add(payload); + console.log( + "[sendNotification] Notification created", + JSON.stringify({ id, receiver, receiverCollection }, null, 2) + ); return id; } catch (e) { - console.log(e); + console.log("[sendNotification] Error creating notification:", e); } }; diff --git a/src/components/BaseHeader.js b/src/components/BaseHeader.js index cd578a7..ce1b7d5 100644 --- a/src/components/BaseHeader.js +++ b/src/components/BaseHeader.js @@ -9,9 +9,13 @@ import { Routes } from "../navigation"; import { navigate } from "../navigation/NavigationService"; import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js"; +import useNotifications from "../hooks/useNotifications"; export default ({ title = "", containerStyle = {} }) => { const { isDesktop } = useLayoutType(); + const notificationsContext = useNotifications(); + const unreadCount = notificationsContext?.unreadCount || 0; + const hasUnread = unreadCount > 0; return ( { }} /> - + {hasUnread ? ( + + ) : null} diff --git a/src/config/firebase.js b/src/config/firebase.js index 8825565..780b00c 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -50,6 +50,7 @@ export const projectsRef = firestore.collection("projects"); export const tasksRef = firestore.collection("tasks"); export const playlistsRef = firestore.collection("playlists"); export const chatsRef = firestore.collection("chats"); +export const notificationsRef = firestore.collection("notifications"); export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue; diff --git a/src/config/initialGlobalState.js b/src/config/initialGlobalState.js index 0290d28..66d499d 100644 --- a/src/config/initialGlobalState.js +++ b/src/config/initialGlobalState.js @@ -6,6 +6,7 @@ export default { currentUserRoles: [], activeRouteName: null, + pendingData: null, webBackgroundImg: null, webLayoutMode: "default", diff --git a/src/hooks/useNotifications.js b/src/hooks/useNotifications.js new file mode 100644 index 0000000..7e10f67 --- /dev/null +++ b/src/hooks/useNotifications.js @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { NotificationContext } from "../providers/NotificationProvider"; + +export default function useNotifications() { + return useContext(NotificationContext); +} diff --git a/src/providers/NotificationProvider.js b/src/providers/NotificationProvider.js index a91ecf5..6635603 100644 --- a/src/providers/NotificationProvider.js +++ b/src/providers/NotificationProvider.js @@ -1,9 +1,21 @@ import Constants from "expo-constants"; import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; -import React, { createContext, useEffect, useRef, useState } from "react"; +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useGlobal } from "reactn"; -import { usersRef } from "../config/firebase"; +import firebase, { + notificationsRef, + serverTimestamp, + usersRef, +} from "../config/firebase"; +import useDataFromRef from "../hooks/useDataFromRef"; export const NotificationContext = createContext(null); @@ -18,54 +30,140 @@ export default function NotificationProvider({ children }) { const notificationListener = useRef(); const responseListener = useRef(); - useEffect(() => { - if (!!uid && !!user && !notifInit) { - registerForPushNotificationsAsync(); - } else if (notifInit && allowNotifications) { - notificationListener.current = - Notifications.addNotificationReceivedListener(async (notification) => { - console.log("Notification received", notification); - await Notifications.scheduleNotificationAsync(notification?.request); - }); + const notificationsQuery = useMemo(() => { + if (!uid) { + return null; + } + try { + return notificationsRef + .where("receiver", "==", uid) + .orderBy("time", "desc"); + } catch (error) { + console.log("notificationsQuery error:", error); + return null; + } + }, [uid]); - responseListener.current = - Notifications.addNotificationResponseReceivedListener( - async (response) => { - console.log("Notification response", response); - if ( - response?.actionIdentifier === - "expo.modules.notifications.actions.DEFAULT" - ) { - const { data } = response.notification.request.content; - if (data) { - console.log("Notification data", data); - await setPendingData(data); - } + const { + data: notifications = [], + loading: loadingNotifications, + setData: setNotifications, + } = useDataFromRef({ + ref: notificationsQuery, + initialState: [], + refreshArray: [uid], + listener: true, + condition: !!notificationsQuery, + }); + + const unreadCount = useMemo(() => { + try { + return notifications.filter((notif) => !notif?.read).length; + } catch (_error) { + return 0; + } + }, [notifications]); + + const markNotificationAsRead = useCallback(async (notificationId) => { + try { + if (!notificationId) return; + await notificationsRef.doc(notificationId).set( + { + read: true, + readAt: serverTimestamp(), + }, + { merge: true } + ); + } catch (error) { + console.log("markNotificationAsRead error:", error); + } + }, []); + + const markAllNotificationsAsRead = useCallback(async () => { + try { + if (!notifications?.length) { + return; + } + + const unread = notifications.filter((notif) => !notif?.read); + if (!unread.length) { + return; + } + + const batch = firebase.firestore().batch(); + unread.forEach((notif) => { + if (!notif?.id) return; + batch.set( + notificationsRef.doc(notif.id), + { + read: true, + readAt: serverTimestamp(), + }, + { merge: true } + ); + }); + await batch.commit(); + } catch (error) { + console.log("markAllNotificationsAsRead error:", error); + } + }, [notifications]); + + useEffect(() => { + if (!uid || !user || notifInit) { + return; + } + registerForPushNotificationsAsync(); + }, [user, uid, notifInit]); + + useEffect(() => { + if (!allowNotifications) { + return; + } + notificationListener.current = + Notifications.addNotificationReceivedListener(async (notification) => { + console.log("Notification received", notification); + await Notifications.scheduleNotificationAsync(notification?.request); + }); + + responseListener.current = + Notifications.addNotificationResponseReceivedListener( + async (response) => { + console.log("Notification response", response); + if ( + response?.actionIdentifier === + "expo.modules.notifications.actions.DEFAULT" + ) { + const { data } = response.notification.request.content; + if (data) { + console.log("Notification data", data); + await setPendingData(data); } } - ); - } + } + ); return () => { - if (notifInit && allowNotifications) { - Notifications.removeNotificationSubscription( - notificationListener.current - ); - Notifications.removeNotificationSubscription(responseListener.current); - } + Notifications.removeNotificationSubscription( + notificationListener.current + ); + Notifications.removeNotificationSubscription(responseListener.current); }; - }, [user, uid, notifInit]); + }, [allowNotifications, setPendingData]); async function registerForPushNotificationsAsync() { try { if (Device.isDevice) { - const { status } = await Notifications.requestPermissionsAsync(); + let { status } = await Notifications.getPermissionsAsync(); + if (status !== "granted") { + const request = await Notifications.requestPermissionsAsync(); + status = request.status; + } if (status !== "granted") { console.log("Notification permissions denied"); + setAllowNotifications(false); return; } setAllowNotifications(true); - setNotifInit(true); // Get the push token const pushToken = ( @@ -86,6 +184,8 @@ export default function NotificationProvider({ children }) { } } catch (e) { console.log(e); + } finally { + setNotifInit(true); } } @@ -95,6 +195,12 @@ export default function NotificationProvider({ children }) { allowNotifications, pendingData, setPendingData, + notifications, + setNotifications, + loadingNotifications, + unreadCount, + markNotificationAsRead, + markAllNotificationsAsRead, }} > {children}