diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 8483f67..1ab0a61 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -165,14 +165,17 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) => }, [user, ready, redirectOverride]); useEffect(() => { - const sub = Notifications.addNotificationReceivedListener(notification => { + const handleNotification = async (notification: Notifications.Notification) => { const eventId = notification?.request?.content?.data?.eventId; - if (eventId) { - queryClient.invalidateQueries(['events']); - } - }); - return () => sub.remove() + // if (eventId) { + queryClient.invalidateQueries(['events']); + // } + }; + + const sub = Notifications.addNotificationReceivedListener(handleNotification); + + return () => sub.remove(); }, []); if (!ready) { diff --git a/firebase/functions/index.js b/firebase/functions/index.js index a524e50..24ab442 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -14,6 +14,11 @@ let notificationTimeout = null; let eventCount = 0; let pushTokens = []; +const GOOGLE_CALENDAR_ID = "primary"; +const CHANNEL_ID = "unique-channel-id"; +const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification"; + + exports.sendNotificationOnEventCreation = functions.firestore .document('Events/{eventId}') .onCreate(async (snapshot, context) => { @@ -304,4 +309,150 @@ async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) { async function removeInvalidPushToken(pushToken) { // TODO -} \ No newline at end of file +} + +async function refreshGoogleToken(refreshToken) { + try { + const response = await axios.post("https://oauth2.googleapis.com/token", { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com", + }); + return response.data.access_token; + } catch (error) { + console.error("Error refreshing Google token:", error.message); + throw error; + } +} + +// Helper function to get Google access tokens for all users and refresh them if needed +async function getGoogleAccessTokens() { + const tokens = {}; + const profilesSnapshot = await db.collection("Profiles").get(); + + await Promise.all( + profilesSnapshot.docs.map(async (doc) => { + const profileData = doc.data(); + const googleAccounts = profileData.googleAccounts || {}; + + for (const googleEmail of Object.keys(googleAccounts)) { + const { refreshToken } = googleAccounts[googleEmail]; + if (refreshToken) { + try { + const accessToken = await refreshGoogleToken(refreshToken); + tokens[doc.id] = accessToken; + } catch (error) { + console.error(`Failed to refresh token for user ${doc.id}:`, error.message); + } + } + } + }) + ); + + return tokens; +} + +// Function to watch Google Calendar events +const watchCalendarEvents = async (userId, token) => { + const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: `${CHANNEL_ID}-${userId}`, // Unique ID per user + type: "web_hook", + address: `${WEBHOOK_URL}?userId=${userId}`, // Pass userId to identify notifications + params: { + ttl: "80000", // Set to 20 hours in seconds + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Failed to watch calendar: ${errorData.error?.message || response.statusText}`); + } + + return response.json(); +}; + +// Schedule function to renew Google Calendar watch every 20 hours for each user +exports.renewGoogleCalendarWatch = functions.pubsub.schedule("every 20 hours").onRun(async (context) => { + try { + const tokens = await getGoogleAccessTokens(); + + for (const [userId, token] of Object.entries(tokens)) { + try { + const result = await watchCalendarEvents(userId, token); + console.log(`Successfully renewed Google Calendar watch for user ${userId}:`, result); + } catch (error) { + console.error(`Error renewing Google Calendar watch for user ${userId}:`, error.message); + } + } + } catch (error) { + console.error("Error in renewGoogleCalendarWatch function:", error.message); + } +}); + +// Function to handle notifications from Google Calendar for a specific user +exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { + const userId = req.query.userId; // Extract userId from query params + const calendarId = req.body.resourceId; + + // Fetch push tokens for the specific user + const userDoc = await db.collection("Profiles").doc(userId).get(); + const userData = userDoc.data(); + const pushTokens = userData ? userData.pushToken : []; + + if (pushTokens.length === 0) { + console.log(`No push tokens found for user ${userId}`); + res.status(200).send("No push tokens found for user."); + return; + } + + const syncMessage = "New events have been synced."; + + let messages = pushTokens.map(pushToken => { + if (!Expo.isExpoPushToken(pushToken)) { + console.error(`Push token ${pushToken} is not a valid Expo push token`); + return null; + } + + return { + to: pushToken, + sound: "default", + title: "Event Sync", + body: syncMessage, + data: { userId, calendarId }, + }; + }).filter(Boolean); + + let chunks = expo.chunkPushNotifications(messages); + let tickets = []; + + for (let chunk of chunks) { + try { + let ticketChunk = await expo.sendPushNotificationsAsync(chunk); + tickets.push(...ticketChunk); + + for (let ticket of ticketChunk) { + if (ticket.status === "ok") { + console.log("Notification successfully sent:", ticket.id); + } else if (ticket.status === "error") { + console.error(`Notification error: ${ticket.message}`); + if (ticket.details?.error === "DeviceNotRegistered") { + await removeInvalidPushToken(ticket.to); + } + } + } + } catch (error) { + console.error("Error sending notification:", error.message); + } + } + + res.status(200).send("Sync notification sent."); +}); \ No newline at end of file diff --git a/hooks/useCalSync.ts b/hooks/useCalSync.ts index 20af736..42620e1 100644 --- a/hooks/useCalSync.ts +++ b/hooks/useCalSync.ts @@ -8,6 +8,8 @@ import * as WebBrowser from "expo-web-browser"; import * as Google from "expo-auth-session/providers/google"; import * as AuthSession from "expo-auth-session"; import * as AppleAuthentication from "expo-apple-authentication"; +import * as Notifications from 'expo-notifications'; +import {useQueryClient} from "react-query"; const googleConfig = { androidClientId: @@ -44,6 +46,7 @@ const microsoftConfig = { export const useCalSync = () => { const {profileData} = useAuthContext(); + const queryClient = useQueryClient(); const {mutateAsync: updateUserData} = useUpdateUserData(); const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = useFetchAndSaveGoogleEvents(); @@ -235,6 +238,35 @@ export const useCalSync = () => { }; + const resyncAllCalendars = async (): Promise => { + try { + const syncPromises: Promise[] = []; + + if (profileData?.googleAccounts) { + for (const [email, { accessToken, refreshToken }] of Object.entries(profileData.googleAccounts)) { + syncPromises.push(fetchAndSaveGoogleEvents({ token: accessToken, refreshToken, email })); + } + } + + if (profileData?.microsoftAccounts) { + for (const [email, accessToken] of Object.entries(profileData.microsoftAccounts)) { + syncPromises.push(fetchAndSaveOutlookEvents(accessToken, email)); + } + } + + if (profileData?.appleAccounts) { + for (const [email, token] of Object.entries(profileData.appleAccounts)) { + syncPromises.push(fetchAndSaveAppleEvents({ token, email })); + } + } + + await Promise.all(syncPromises); + console.log("All calendars have been resynced."); + } catch (error) { + console.error("Error resyncing calendars:", error); + } + }; + let isConnectedToGoogle = false; if (profileData?.googleAccounts) { Object.values(profileData?.googleAccounts).forEach((item) => { @@ -267,6 +299,20 @@ export const useCalSync = () => { } + useEffect(() => { + const handleNotification = async (notification: Notifications.Notification) => { + const eventId = notification?.request?.content?.data?.eventId; + + await resyncAllCalendars(); + queryClient.invalidateQueries(["events"]); + }; + + const sub = Notifications.addNotificationReceivedListener(handleNotification); + + return () => sub.remove(); + }, []); + + return { handleAppleSignIn, handleMicrosoftSignIn, @@ -281,6 +327,7 @@ export const useCalSync = () => { isSyncingOutlook, isSyncingGoogle, isSyncingApple, + resyncAllCalendars, isSyncing: isSyncingApple || isSyncingOutlook || isSyncingGoogle } } \ No newline at end of file diff --git a/ios/cally/Info.plist b/ios/cally/Info.plist index da7ee07..4c9788a 100644 --- a/ios/cally/Info.plist +++ b/ios/cally/Info.plist @@ -152,6 +152,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route UILaunchStoryboardName SplashScreen