diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index f21ac2b..7090da7 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -1,8 +1,10 @@ import { App as mobileApp } from '@capacitor/app' import { Capacitor } from '@capacitor/core' +import { Device } from '@capacitor/device' import { LocalNotifications } from '@capacitor/local-notifications' +import { Preferences } from '@capacitor/preferences' import { PushNotifications } from '@capacitor/push-notifications' -import { PutNotificationTarget } from './utils/Fetcher' +import { RegisterDeviceToken } from './utils/Fetcher' const localNotificationListenerRegistration = () => { LocalNotifications.addListener('localNotificationReceived', notification => { console.log('Notification received', notification) @@ -18,41 +20,117 @@ const localNotificationListenerRegistration = () => { } }) } -const pushNotificationListenerRegistration = () => { - PushNotifications.register() - PushNotifications.addListener('registration', token => { - if (Capacitor.isNativePlatform()) { - const type = Capacitor.getPlatform() === 'android' ? 1 : 2 // 1 for android, 2 for ios - PutNotificationTarget(type, token.value) - .then(response => { - console.log('Notification target updated', response) - }) - .catch(error => { - console.error('Error updating notification target', error) - }) - // TODO save the token in preferences and only send it if it has changed: - console.log('Push registration success, token: ' + token.value) +const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => { + try { + const stored = await Preferences.get({ key: 'deviceRegistration' }) + const lastReg = stored.value ? JSON.parse(stored.value) : null + + const current = { + token: token.value, + deviceId: deviceId.identifier, + platform, + appVersion: deviceInfo.appVersion, + registeredAt: Date.now(), } - }) - PushNotifications.addListener('registrationError', error => { - console.error('Error on registration: ' + JSON.stringify(error)) - }) - PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => { - if (fcmEvent.actionId === 'tap') { - if (fcmEvent.notification.data.type === 'chore_due') { - window.location.href = `/chores/${fcmEvent.notification.data.choreId}` - } else { - window.location.href = `/chores` + + const shouldRegister = + !lastReg || + lastReg.token !== current.token || + lastReg.appVersion !== current.appVersion || + Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000 + + if (shouldRegister) { + console.log('Registering device token:', { + reason: !lastReg + ? 'first_time' + : lastReg.token !== current.token + ? 'token_changed' + : lastReg.appVersion !== current.appVersion + ? 'app_updated' + : 'periodic_refresh', + }) + + const result = await RegisterDeviceToken( + token.value, + deviceId.identifier, + platform, + deviceInfo.appVersion, + deviceInfo.model, + ) + + if (result && !result.error) { + await Preferences.set({ + key: 'deviceRegistration', + value: JSON.stringify(current), + }) + console.log('Device token registered successfully') + } + } else { + console.log('Device token already registered, skipping') + } + } catch (error) { + console.error( + 'Error in token registration check, registering anyway:', + error, + ) + await RegisterDeviceToken( + token.value, + deviceId.identifier, + platform, + deviceInfo.appVersion, + deviceInfo.model, + ) + } +} + +const pushNotificationListenerRegistration = async () => { + // Check and request permissions for Android 13+ + if (Capacitor.isNativePlatform()) { + let permStatus = await PushNotifications.checkPermissions() + + if (permStatus.receive === 'prompt') { + permStatus = await PushNotifications.requestPermissions() + } + + if (permStatus.receive !== 'granted') { + console.warn('Push notification permission not granted') + return + } + } + + await PushNotifications.register() + + PushNotifications.addListener('registration', async token => { + if (Capacitor.isNativePlatform()) { + try { + const deviceInfo = await Device.getInfo() + const deviceId = await Device.getId() + + const platform = + Capacitor.getPlatform() === 'android' ? 'android' : 'ios' + + await registerTokenIfNeeded(token, deviceInfo, deviceId, platform) + } catch (error) { + console.error('Error registering device token', error) } } }) + PushNotifications.addListener('registrationError', error => { console.error('Error on registration: ' + JSON.stringify(error)) }) + + PushNotifications.addListener('pushNotificationReceived', notification => { + console.log('Push notification received: ', notification) + }) + PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => { if (fcmEvent.actionId === 'tap') { - if (fcmEvent.notification.data.type === 'chore_due') { + if ( + fcmEvent.notification.data.type === 'chore_due' || + fcmEvent.notification.data.type === 'nudge' + ) { window.location.href = `/chores/${fcmEvent.notification.data.choreId}` } else { window.location.href = `/chores` @@ -69,7 +147,6 @@ const registerCapacitorListeners = () => { return } localNotificationListenerRegistration() - pushNotificationListenerRegistration() mobileApp.addListener('backButton', ({ canGoBack }) => { if (canGoBack) { window.history.back() @@ -79,4 +156,7 @@ const registerCapacitorListeners = () => { }) } -export { registerCapacitorListeners } +export { + registerCapacitorListeners, + pushNotificationListenerRegistration as registerPushNotifications, +} diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx index 7e1566d..2b9dd53 100644 --- a/src/components/UserProfileAvatar.jsx +++ b/src/components/UserProfileAvatar.jsx @@ -530,7 +530,7 @@ const UserProfileAvatar = () => { /> setIsSubscriptionModalOpen(false)} /> diff --git a/src/components/animations/index.js b/src/components/animations/index.js index 54af463..9bb9927 100644 --- a/src/components/animations/index.js +++ b/src/components/animations/index.js @@ -1,6 +1,5 @@ // Animation Components export { default as AnimatedList } from './AnimatedList' -export { default as LoadingScreen } from './LoadingScreen' export { default as PageTransition } from './PageTransition' export { default as SmoothButton } from './SmoothButton' export { default as SmoothCard } from './SmoothCard' diff --git a/src/contexts/QueryContext.jsx b/src/contexts/QueryContext.jsx index 3087d65..8de0668 100644 --- a/src/contexts/QueryContext.jsx +++ b/src/contexts/QueryContext.jsx @@ -1,7 +1,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const QueryContext = ({ children }) => { - const queryClient = new QueryClient() + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60000, // 60 seconds + gcTime: 300000, // 5 minutes + refetchOnWindowFocus: false, + retry: 0, + }, + }, + }) return ( {children} diff --git a/src/main.jsx b/src/main.jsx index c9aa258..ab34363 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,15 +1,12 @@ -import { QueryClient } from '@tanstack/react-query' import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' import Contexts from './contexts/Contexts.jsx' import './index.css' -const queryClient = new QueryClient({}) - ReactDOM.createRoot(document.getElementById('root')).render( - + , diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index dfc58ce..f188f47 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -172,6 +172,17 @@ const RejectChore = id => { }) } +const NudgeChore = (id, { message, notifyAllAssignees }) => { + return Fetch(`/chores/${id}/nudge`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ + all_assignees: notifyAllAssignees, + message: message || '', + }), + }) +} + const UpdateChoreAssignee = (id, assignee) => { return Fetch(`/chores/${id}/assignee`, { method: 'PUT', @@ -657,6 +668,38 @@ const RestoreBackup = (encryptionKey, backupData) => { }) } +const RegisterDeviceToken = (token, deviceId, platform, appVersion, deviceModel) => { + return Fetch(`/devices/tokens`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ + token, + deviceId, + platform, + appVersion, + deviceModel, + }), + }) +} + +const UnregisterDeviceToken = (deviceId, token) => { + return Fetch(`/devices/tokens`, { + method: 'DELETE', + headers: HEADERS(), + body: JSON.stringify({ + deviceId, + token, + }), + }) +} + +const GetDeviceTokens = (active = true) => { + return Fetch(`/devices/tokens?active=${active}`, { + method: 'GET', + headers: HEADERS(), + }) +} + export { AcceptCircleMemberRequest, ApproveChore, @@ -692,6 +735,7 @@ export { GetChoresHistory, GetChoresNew, GetCircleMemberRequests, + GetDeviceTokens, GetLabels, GetLongLiveTokens, GetMFAStatus, @@ -705,12 +749,14 @@ export { JoinCircle, LeaveCircle, MarkChoreComplete, + NudgeChore, PauseChore, PutNotificationTarget, PutWebhookURL, RedeemPoints, RefreshToken, RegenerateBackupCodes, + RegisterDeviceToken, RejectChore, ResetChoreTimer, ResetPassword, @@ -721,6 +767,7 @@ export { SkipChore, StartChore, UnArchiveChore, + UnregisterDeviceToken, UpdateChoreAssignee, UpdateChoreHistory, UpdateChorePriority, diff --git a/src/views/Chores/NotificationAccessSnackbar.jsx b/src/views/Chores/NotificationAccessSnackbar.jsx index 15304be..6a940d7 100644 --- a/src/views/Chores/NotificationAccessSnackbar.jsx +++ b/src/views/Chores/NotificationAccessSnackbar.jsx @@ -3,6 +3,7 @@ import { LocalNotifications } from '@capacitor/local-notifications' import { Preferences } from '@capacitor/preferences' import { Button, Snackbar, Stack, Typography } from '@mui/joy' import { useEffect, useState } from 'react' +import { registerPushNotifications } from '../../CapacitorListener' const NotificationAccessSnackbar = () => { const [open, setOpen] = useState(false) @@ -56,14 +57,20 @@ const NotificationAccessSnackbar = () => {