From dc652c1631463d9e34c8d9fb6917a1eb39b3fd70 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 8 Oct 2025 09:25:45 +0200 Subject: [PATCH] web notifications --- functions/src/notifications.js | 160 +++++++++++++++++++++----- src/providers/NotificationProvider.js | 75 +++++++----- 2 files changed, 176 insertions(+), 59 deletions(-) diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 216cc74..2d7f05a 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -19,23 +19,34 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( data: notifData = {}, } = event.data.data(); - const { pushToken = null } = - (await db.collection(receiverCollection).doc(receiver).get())?.data() || - {}; - if (!receiver || !message) { throw new Error("Receiver and message are required"); } - if (pushToken) { - await sendExpoNotification(pushToken, { - title: title || "MusicLand", - message: message, - data: notifData || {}, - }); - } else { + const userSnap = await db.collection(receiverCollection).doc(receiver).get(); + const { pushToken = null, pushTokens = [] } = userSnap?.data() || {}; + + const tokensSet = new Set( + [] + .concat(Array.isArray(pushTokens) ? pushTokens : []) + .concat(pushToken ? [pushToken] : []) + .filter(Boolean) + ); + const tokens = Array.from(tokensSet); + + if (!tokens?.length) { console.warn("User push token not found"); + return null; } + + await sendExpoNotification({ + tokens, + receiverId: receiver, + receiverCollection, + title: title || "MusicLand", + message: message, + data: notifData || {}, + }); } catch (e) { console.log(e); return e; @@ -44,29 +55,83 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( ); // Fonction pour envoyer la notification via Expo SDK -async function sendExpoNotification( - pushToken, - { title = "", message = "", data = {} } -) { +async function sendExpoNotification({ + tokens = [], + title = "", + message = "", + data = {}, + receiverId = null, + receiverCollection = "users", +}) { try { - if (!Expo.isExpoPushToken(pushToken)) { - throw new Error(`Invalid push token: ${pushToken}`); - } else { - console.log("Valid push token:", pushToken); + const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]; + const validTokens = []; + const invalidTokens = []; + + candidateTokens.forEach((token) => { + if (Expo.isExpoPushToken(token)) { + validTokens.push(token); + } else if (token) { + invalidTokens.push(token); + } + }); + + if (invalidTokens.length && receiverId) { + await removeInvalidTokens({ + tokens: invalidTokens, + receiverId, + receiverCollection, + }); + } + + if (!validTokens.length) { + console.warn("No valid Expo push tokens to send notification"); + return { sent: false }; + } + + const messages = validTokens.map((token) => ({ + to: token, + sound: "default", + title: title, + body: message, + data: data || {}, + priority: "high", + badge: 1, + channelId: "default", + })); + + const chunks = expo.chunkPushNotifications(messages); + const receipts = []; + const tokensToPrune = new Set(); + + for (const chunk of chunks) { + const chunkReceipts = await expo.sendPushNotificationsAsync(chunk); + chunkReceipts.forEach((receipt, index) => { + if (receipt?.status === "error") { + const errorCode = receipt?.details?.error || receipt?.details?.code; + console.log("Error sending notification:", receipt); + if ( + errorCode === "DeviceNotRegistered" || + errorCode === "PushTokenNotRegistered" + ) { + const token = chunk[index]?.to; + if (token) { + tokensToPrune.add(token); + } + } + } + }); + receipts.push(...chunkReceipts); + } + + if (tokensToPrune.size && receiverId) { + await removeInvalidTokens({ + tokens: Array.from(tokensToPrune), + receiverId, + receiverCollection, + }); } - const receipts = await expo.sendPushNotificationsAsync([ - { - to: pushToken, - sound: "default", - title: title, - body: message, - data: data || {}, - priority: "high", - badge: 1, - channelId: "default", - }, - ]); console.log("Sent push notifications:", receipts); return { sent: true }; @@ -76,6 +141,39 @@ async function sendExpoNotification( } } +async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) { + try { + if (!receiverId || !tokens.length) { + return; + } + + const uniqueTokens = Array.from(new Set(tokens.filter(Boolean))); + if (!uniqueTokens.length) { + return; + } + + const docRef = db.collection(receiverCollection).doc(receiverId); + const userSnap = await docRef.get(); + const userData = userSnap?.data() || {}; + + const updates = { + pushTokens: admin.firestore.FieldValue.arrayRemove(...uniqueTokens), + }; + + if (uniqueTokens.includes(userData?.pushToken)) { + updates.pushToken = admin.firestore.FieldValue.delete(); + } + + await docRef.set(updates, { merge: true }); + console.log( + "Pruned invalid push tokens", + JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2) + ); + } catch (error) { + console.log("Failed to prune invalid push tokens:", error); + } +} + // Fonction pour ajouter une notification à la base de données const sendNotification = async ({ sender = "SYSTEM", diff --git a/src/providers/NotificationProvider.js b/src/providers/NotificationProvider.js index 6635603..221cf05 100644 --- a/src/providers/NotificationProvider.js +++ b/src/providers/NotificationProvider.js @@ -9,8 +9,10 @@ import React, { useRef, useState, } from "react"; +import { Platform } from "react-native"; import { useGlobal } from "reactn"; import firebase, { + arrayUnion, notificationsRef, serverTimestamp, usersRef, @@ -152,35 +154,52 @@ export default function NotificationProvider({ children }) { async function registerForPushNotificationsAsync() { try { - if (Device.isDevice) { - 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); - - // Get the push token - const pushToken = ( - await Notifications.getExpoPushTokenAsync({ - projectId: Constants.expoConfig?.extra?.eas?.projectId || "", - }) - ).data; - if (user?.pushToken !== pushToken && !!user) { - await usersRef.doc(uid).update({ pushToken }); - // if (!user?.alerts) { - // await usersRef.doc(uid).update({ - // alerts: Object.values(ALERT_TYPE), - // }); - // } - } - } else { + const isWeb = Platform.OS === "web"; + if (!Device.isDevice && !isWeb) { console.log("Must use physical device for Push Notifications"); + setAllowNotifications(false); + setNotifInit(true); + return; + } + + 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); + + const projectId = + Constants.expoConfig?.extra?.eas?.projectId || + Constants.manifest2?.extra?.eas?.projectId || + Constants.manifest?.extra?.eas?.projectId || + ""; + + const pushToken = ( + await Notifications.getExpoPushTokenAsync({ + projectId, + }) + )?.data; + + if (!pushToken) { + console.log("No push token retrieved"); + return; + } + + if (!!user && pushToken) { + await usersRef.doc(uid).set( + { + pushTokens: arrayUnion(pushToken), + }, + { merge: true } + ); } } catch (e) { console.log(e);