From 08f8d6ac6aceb958765ae90b020ae932a4b92e9f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 17 Jul 2026 19:33:33 -0400 Subject: [PATCH] Fix Notification deeplink --- src/App.jsx | 8 +- src/CapacitorListener.js | 184 +++++++++++++++++++-------------------- 2 files changed, 95 insertions(+), 97 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index d8ef454..8bae287 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,7 @@ import NavBar from '@/views/components/NavBar' import { Button, Typography, useColorScheme } from '@mui/joy' import { useCallback, useEffect } from 'react' -import { Outlet } from 'react-router-dom' +import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import PageTransition from './components/animations/PageTransition' @@ -36,7 +36,6 @@ const AppContent = () => { // Initialize status bar with theme-aware configuration useStatusBar() - const { offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars needRefresh: [needRefresh, setNeedRefresh], @@ -99,6 +98,7 @@ const AppContent = () => { function App() { const resource = useResource() // eslint-disable-line no-unused-vars const { mode, systemMode } = useColorScheme() + const navigate = useNavigate() // startOpenReplay() @@ -124,8 +124,8 @@ function App() { }, [setThemeClass]) useEffect(() => { - registerCapacitorListeners() - }, []) + registerCapacitorListeners(navigate) + }, [navigate]) return (
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index 8150569..e4c00ce 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -8,30 +8,62 @@ import { PushNotifications } from '@capacitor/push-notifications' import { focusManager } from '@tanstack/react-query' import { RegisterDeviceToken } from './utils/Fetcher' +// React Router navigate(), injected by once the router is mounted. +// Using client-side navigation (instead of window.location.href) avoids a full +// document reload, which on a cold NFC launch re-boots the app from an +// unauthenticated state (iOS "server/connection" error) and re-triggers the +// sticky getLaunchUrl() (Android "back reloads the same page"). +let navigateFn = null +const setNavigate = fn => { + navigateFn = fn +} + +// Navigate client-side when the router is available, otherwise fall back to a +// hard navigation (should only happen if a deep link arrives before mount). +const routerNavigate = (path, { seedHome = false } = {}) => { + if (navigateFn) { + // On a cold deep-link launch there is no real screen behind the target, so + // seed the chore list as the back target before pushing the chore view. + if (seedHome && window.location.pathname === '/') { + navigateFn('/chores', { replace: true }) + } + navigateFn(path) + } else { + window.location.href = path + } +} + +// Navigate to a chore from a deep link / notification tap. Shared by NFC, +// local and push notifications so they all get consistent cold-start handling. +const navigateToChore = (choreId, { autoComplete, isColdStart } = {}) => { + if (choreId == null || choreId === '') return + const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}` + + // If we're already on the target page, skip (avoids redundant navigation). + if (window.location.pathname + window.location.search === path) return + + console.log('[deeplink] navigating to', path) + routerNavigate(path, { seedHome: isColdStart }) +} + // NFC chore deep link: donetick://chores/123?auto_complete=true -const handleNFCChoreDeepLink = url => { +const handleNFCChoreDeepLink = (url, isColdStart) => { try { const urlObj = new URL(url) // donetick://chores/123 → host='chores', pathname='/123' - const choreId = urlObj.pathname.slice(1) - const autoComplete = urlObj.searchParams.get('auto_complete') - const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}` - - // getLaunchUrl() persists across every WebView reload caused by window.location.href. - // If we're already on the target page, skip to avoid an infinite reload loop. - if (window.location.pathname + window.location.search === path) return - - console.log('[NFC] navigating to', path) - window.location.href = path + navigateToChore(urlObj.pathname.slice(1), { + autoComplete: urlObj.searchParams.get('auto_complete'), + isColdStart, + }) } catch (error) { console.error('[NFC] Error handling chore deep link:', error) } } -const handleUrlOpen = url => { +const handleUrlOpen = (url, isColdStart = false) => { console.log('[NFC] handleUrlOpen:', url) if (url.startsWith('donetick://chores/')) { - handleNFCChoreDeepLink(url) + handleNFCChoreDeepLink(url, isColdStart) } else if (url.startsWith('donetick://auth/')) { handleOAuthDeepLink(url) } @@ -48,9 +80,8 @@ const handleOAuthDeepLink = async url => { const state = urlObj.searchParams.get('state') if (code && state) { - // getLaunchUrl() persists across every WebView reload caused by - // window.location.href. If we're already on the OAuth handler page with - // the same code, skip re-navigating to avoid an infinite reload loop. + // If we're already on the OAuth handler page with the same code, skip + // re-navigating (a stale getLaunchUrl() replay would otherwise re-fire it). const currentCode = new URLSearchParams(window.location.search).get( 'code', ) @@ -72,7 +103,9 @@ const handleOAuthDeepLink = async url => { } // Navigate to the OAuth handler page - window.location.href = `/auth/oauth2?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + routerNavigate( + `/auth/oauth2?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, + ) } } catch (error) { console.error('Error handling OAuth deep link:', error) @@ -90,40 +123,13 @@ const localNotificationListenerRegistration = () => { 'Notification opened, navigate to chore', event.notification.extra.choreId, ) - window.location.href = `/chores/${event.notification.extra.choreId}` + navigateToChore(event.notification.extra.choreId, { isColdStart: true }) } }) } 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(), - } - - // const shouldRegister = - // !lastReg || - // lastReg.token !== current.token || - // lastReg.appVersion !== current.appVersion || - // Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000 - - // 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, @@ -132,20 +138,23 @@ const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => { deviceInfo.model, ) - if (result && result.ok) { + if (!result) return + + if (result.ok) { await Preferences.set({ key: 'deviceRegistration', - value: JSON.stringify(current), + value: JSON.stringify({ + token: token.value, + deviceId: deviceId.identifier, + platform, + appVersion: deviceInfo.appVersion, + registeredAt: Date.now(), + }), }) console.log('Device token registered successfully') - - // Emit event to notify UI components of successful registration window.dispatchEvent(new CustomEvent('deviceTokenRegistered')) - } else if (result) { - // Handle registration errors + } else { console.error('Device registration failed:', result.status) - - // Emit event with error details for UI to handle window.dispatchEvent( new CustomEvent('deviceTokenRegistrationFailed', { detail: { @@ -156,38 +165,12 @@ const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => { ) } } catch (error) { - console.error( - 'Error in token registration check, registering anyway:', - error, + console.error('Error registering device token:', error) + window.dispatchEvent( + new CustomEvent('deviceTokenRegistrationFailed', { + detail: { status: 0, error: error?.message ?? 'Unknown error' }, + }), ) - const fallbackResult = await RegisterDeviceToken( - token.value, - deviceId.identifier, - platform, - deviceInfo.appVersion, - deviceInfo.model, - ) - - if (fallbackResult && fallbackResult.ok) { - // Emit event to notify UI components of successful registration - window.dispatchEvent(new CustomEvent('deviceTokenRegistered')) - } else if (fallbackResult) { - // Handle registration errors - console.error( - 'Fallback device registration failed:', - fallbackResult.status, - ) - - // Emit event with error details for UI to handle - window.dispatchEvent( - new CustomEvent('deviceTokenRegistrationFailed', { - detail: { - status: fallbackResult.status, - error: await fallbackResult.text().catch(() => 'Unknown error'), - }, - }), - ) - } } } @@ -238,35 +221,51 @@ const pushNotificationListenerRegistration = async () => { fcmEvent.notification.data.type === 'chore_due' || fcmEvent.notification.data.type === 'nudge' ) { - window.location.href = `/chores/${fcmEvent.notification.data.choreId}` + navigateToChore(fcmEvent.notification.data.choreId, { + isColdStart: true, + }) } else { - window.location.href = `/chores` + routerNavigate('/chores') } } }) } -const registerCapacitorListeners = () => { +let launchUrlHandled = false +let listenersRegistered = false + +const registerCapacitorListeners = navigate => { + if (navigate) setNavigate(navigate) + if (!Capacitor.isNativePlatform()) { console.log( 'Not a native platform, skipping registration of native listeners', ) return } + + // registerCapacitorListeners runs from a React effect and may fire more than + // once; only wire up the native listeners a single time. + if (listenersRegistered) return + listenersRegistered = true + localNotificationListenerRegistration() - // Cold-start: app was launched by tapping an NFC tag (or other deep link) + // Cold-start: app was launched by tapping an NFC tag (or other deep link). + // getLaunchUrl() is sticky and keeps returning the launch URL across reloads, + // so consume it exactly once to avoid re-triggering navigation on every boot. mobileApp.getLaunchUrl().then(result => { - if (result?.url) { + if (result?.url && !launchUrlHandled) { + launchUrlHandled = true console.log('[NFC] getLaunchUrl:', result.url) - handleUrlOpen(result.url) + handleUrlOpen(result.url, true /* isColdStart */) } }) // Foreground / singleTask resume: app was already running when the tag was tapped mobileApp.addListener('appUrlOpen', event => { console.log('[NFC] appUrlOpen:', event.url) - handleUrlOpen(event.url) + handleUrlOpen(event.url, false /* isColdStart */) }) mobileApp.addListener('appStateChange', ({ isActive }) => { @@ -287,6 +286,5 @@ const registerCapacitorListeners = () => { export { registerCapacitorListeners, - pushNotificationListenerRegistration as registerPushNotifications + pushNotificationListenerRegistration as registerPushNotifications, } -