diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 607863e..5569b48 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -21,6 +21,7 @@ dependencies { implementation project(':capacitor-network') implementation project(':capacitor-preferences') implementation project(':capacitor-push-notifications') + implementation project(':capacitor-share') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-document-scanner') implementation project(':capgo-capacitor-nfc') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c1ddb92..d4ed4b0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,17 @@ + + + + + + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index cef2e20..d94238f 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -38,6 +38,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci include ':capacitor-push-notifications' project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements index aa63eb6..2c2c057 100644 --- a/ios/App/App/App.entitlements +++ b/ios/App/App/App.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:app.donetick.com + com.apple.security.application-groups group.com.donetick.app diff --git a/ios/App/Podfile b/ios/App/Podfile index b538501..caed414 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -23,6 +23,7 @@ def capacitor_pods pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences' pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapgoCapacitorDocumentScanner', :path => '../../node_modules/@capgo/capacitor-document-scanner' pod 'CapgoCapacitorNfc', :path => '../../node_modules/@capgo/capacitor-nfc' diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 90c75a0..9f86a3d 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -43,6 +43,8 @@ PODS: - Capacitor - CapacitorPushNotifications (8.1.2): - Capacitor + - CapacitorShare (8.0.1): + - Capacitor - CapacitorStatusBar (8.0.3): - Capacitor - CapgoCapacitorDocumentScanner (8.4.2): @@ -157,6 +159,7 @@ DEPENDENCIES: - CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`) - "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)" - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" + - "CapacitorShare (from `../../node_modules/@capacitor/share`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - "CapgoCapacitorDocumentScanner (from `../../node_modules/@capgo/capacitor-document-scanner`)" - "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)" @@ -222,6 +225,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/preferences" CapacitorPushNotifications: :path: "../../node_modules/@capacitor/push-notifications" + CapacitorShare: + :path: "../../node_modules/@capacitor/share" CapacitorStatusBar: :path: "../../node_modules/@capacitor/status-bar" CapgoCapacitorDocumentScanner: @@ -256,6 +261,7 @@ SPEC CHECKSUMS: CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9 CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1 CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237 + CapacitorShare: 0c58305114538568059bfc07111f22dcb9cb2a82 CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942 CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027 @@ -283,6 +289,6 @@ SPEC CHECKSUMS: SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c +PODFILE CHECKSUM: 0170e6b548e03117ef7d6814596b8b140a6acce4 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index cd90aa4..acffd35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "donetick", - "version": "1.2.33", + "version": "1.2.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "donetick", - "version": "1.2.33", + "version": "1.2.38", "hasInstallScript": true, "dependencies": { "@capacitor-community/in-app-review": "^8.0.0", @@ -24,6 +24,7 @@ "@capacitor/network": "^8.0.0", "@capacitor/preferences": "^8.0.0", "@capacitor/push-notifications": "^8.0.0", + "@capacitor/share": "^8.0.1", "@capacitor/status-bar": "^8.0.0", "@capgo/capacitor-document-scanner": "^8.4.0", "@capgo/capacitor-nfc": "^8.0.0", @@ -2174,6 +2175,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/share": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz", + "integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/status-bar": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", diff --git a/package.json b/package.json index 36988e2..a0a6fe9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@capacitor/network": "^8.0.0", "@capacitor/preferences": "^8.0.0", "@capacitor/push-notifications": "^8.0.0", + "@capacitor/share": "^8.0.1", "@capacitor/status-bar": "^8.0.0", "@capgo/capacitor-document-scanner": "^8.4.0", "@capgo/capacitor-nfc": "^8.0.0", diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..6e2da30 --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,11 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "6UJJ78R3BS.com.donetick.app", + "paths": ["/circle/join*"] + } + ] + } +} diff --git a/public/.well-known/apple-app-site-association.json b/public/.well-known/apple-app-site-association.json new file mode 100644 index 0000000..70aab72 --- /dev/null +++ b/public/.well-known/apple-app-site-association.json @@ -0,0 +1,11 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "6UJJ78R3BS.com.donetick.app", + "paths": ["/circle/join*"] + } + ] + } +} \ No newline at end of file diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..cebe521 --- /dev/null +++ b/public/.well-known/assetlinks.json @@ -0,0 +1,13 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.donetick.app", + "sha256_cert_fingerprints": [ + "EF:45:27:40:A2:D2:11:E4:27:AB:9A:7A:C6:E1:3B:CA:D4:DE:6A:0A:C3:81:05:58:D8:89:F1:FA:4E:CB:44:F3", + "67:0B:E5:30:FB:8A:7F:E6:9A:54:51:7F:06:AA:B0:1D:1A:26:61:5B:2A:60:53:4A:31:75:72:DA:F6:FA:EC:5A" + ] + } + } +] diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..ec17236 --- /dev/null +++ b/public/_headers @@ -0,0 +1,8 @@ +/.well-known/apple-app-site-association + Content-Type: application/json + +/.well-known/apple-app-site-association.json + Content-Type: application/json + +/.well-known/assetlinks.json + Content-Type: application/json diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..152c5c1 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200 \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index ec3684a..56a7f48 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -42,8 +42,8 @@ const AppContent = () => { recordRoute(location.pathname) }, [location.pathname]) - // // First-launch native users see the onboarding flow before anything else. - useOnboardingGate() + // First-launch native users see the onboarding flow before anything else. + const isRedirectingToOnboarding = useOnboardingGate() // Initialize status bar with theme-aware configuration useStatusBar() @@ -95,6 +95,8 @@ const AppContent = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [needRefresh]) + if (isRedirectingToOnboarding) return null + return (
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index e21d97d..1957455 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -6,8 +6,11 @@ import { LocalNotifications } from '@capacitor/local-notifications' import { Preferences } from '@capacitor/preferences' import { PushNotifications } from '@capacitor/push-notifications' import { focusManager } from '@tanstack/react-query' + import { RegisterDeviceToken } from './utils/Fetcher' import { beginOAuthExchange } from './utils/OAuthExchangeState' +import { hasSeenOnboarding } from './utils/Onboarding' +import { setPendingInvite } from './utils/PendingInvite' // React Router navigate(), injected by once the router is mounted. // Using client-side navigation (instead of window.location.href) avoids a full @@ -63,7 +66,27 @@ const handleNFCChoreDeepLink = (url, isColdStart) => { const handleUrlOpen = (url, isColdStart = false) => { console.log('[NFC] handleUrlOpen:', url) - if (url.startsWith('donetick://chores/add')) { + let parsedUrl + try { + parsedUrl = new URL(url) + } catch { + return + } + + const isCircleInvite = + (parsedUrl.protocol === 'donetick:' && + parsedUrl.host === 'circle' && + parsedUrl.pathname === '/join') || + (parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join') + + if (isCircleInvite) { + setPendingInvite(parsedUrl.searchParams.get('code')) + const needsOnboarding = + !hasSeenOnboarding() && !localStorage.getItem('token') + routerNavigate( + needsOnboarding ? '/onboarding' : `/circle/join${parsedUrl.search}`, + ) + } else if (url.startsWith('donetick://chores/add')) { // Widget "+" / quick-capture buttons: land on the chore list with the // quick-add modal open (MyChores watches for the add_task param and // consumes it). ?mode=scan|voice opens straight into that capture panel. diff --git a/src/hooks/useOnboardingGate.js b/src/hooks/useOnboardingGate.js index d420d14..7f9d6e8 100644 --- a/src/hooks/useOnboardingGate.js +++ b/src/hooks/useOnboardingGate.js @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding' +import { setPendingInvite } from '../utils/PendingInvite' // Routes a first-run user may legitimately be on without having gone through // onboarding: the flow itself, deep-link auth callbacks, and the legal pages @@ -27,16 +28,24 @@ const isAllowed = pathname => */ const useOnboardingGate = () => { const navigate = useNavigate() - const { pathname } = useLocation() + const { pathname, search } = useLocation() + const isRedirecting = + isNativeApp() && + !hasSeenOnboarding() && + !localStorage.getItem('token') && + !isAllowed(pathname) useEffect(() => { - if (!isNativeApp() || hasSeenOnboarding()) return - // A signed-in user upgrading from an older build has nothing to onboard to. - if (localStorage.getItem('token')) return - if (isAllowed(pathname)) return + if (!isRedirecting) return + + if (pathname === '/circle/join') { + setPendingInvite(new URLSearchParams(search).get('code')) + } navigate('/onboarding', { replace: true }) - }, [pathname, navigate]) + }, [isRedirecting, pathname, search, navigate]) + + return isRedirecting } export default useOnboardingGate diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js index d09f20d..b946b6d 100644 --- a/src/hooks/useSyncOnReconnect.js +++ b/src/hooks/useSyncOnReconnect.js @@ -2,9 +2,10 @@ import { App as capacitorApp } from '@capacitor/app' import { Capacitor } from '@capacitor/core' import { useQueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react' + import { commandQueue } from '../utils/CommandQueue' -import { offlineDB } from '../utils/OfflineDB' import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState' +import { offlineDB } from '../utils/OfflineDB' import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' import { syncEngine } from '../utils/SyncEngine' import { networkManager } from './NetworkManager' @@ -91,6 +92,10 @@ export function useSyncOnReconnect() { const runSync = async () => { if (!isOfflineFeatureEnabled()) return + // Public routes (onboarding, login, signup) have no session to sync. + // Calling /sync/changes here returns 401 and the global auth handler + // hard-navigates to /login, which reloads the WebView mid-onboarding. + if (!localStorage.getItem('token')) return // Skip while the OAuth code exchange is in flight — there's no session // yet, so a sync here just 401s. Note the app-resume listener fires in // the same tick as the deep link, before the route changes, so this has diff --git a/src/utils/PendingInvite.js b/src/utils/PendingInvite.js index b5d0059..34cd270 100644 --- a/src/utils/PendingInvite.js +++ b/src/utils/PendingInvite.js @@ -13,7 +13,12 @@ export const joinCirclePath = code => export const setPendingInvite = code => { if (!code) return - localStorage.setItem(INVITE_KEY, code) + + try { + localStorage.setItem(INVITE_KEY, code) + } catch { + // The redirect cookie still preserves the invite through authentication. + } // Every post-auth landing point (password login, OAuth callback, MFA) already // consumes `ca_redirect`, so reusing it is all the routing this needs. Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 }) @@ -31,10 +36,11 @@ export const clearPendingInvite = () => { try { localStorage.removeItem(INVITE_KEY) } catch { - // ignore + // Ignore unavailable storage during cleanup. } + const redirect = Cookies.get(REDIRECT_COOKIE) - if (redirect && redirect.startsWith('/circle/join')) { + if (redirect?.startsWith('/circle/join')) { Cookies.remove(REDIRECT_COOKIE) } } diff --git a/src/views/Onboarding/CircleSetupView.jsx b/src/views/Onboarding/CircleSetupView.jsx index 96784c8..b16d1b0 100644 --- a/src/views/Onboarding/CircleSetupView.jsx +++ b/src/views/Onboarding/CircleSetupView.jsx @@ -6,10 +6,12 @@ import { import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' + import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal' import { useNotification } from '../../service/NotificationProvider' import { GetUserCircle, JoinCircle } from '../../utils/Fetcher' import { haptic } from '../../utils/Onboarding' +import { clearPendingInvite, getPendingInvite } from '../../utils/PendingInvite' import { authButtonSx } from '../Authorization/authStyles' import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal' import { CircleVignette } from './OnboardingVignettes' @@ -81,10 +83,11 @@ const CircleSetupView = () => { const navigate = useNavigate() const { showNotification } = useNotification() const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal() + const pendingInvite = getPendingInvite() - const [mode, setMode] = useState('invite') + const [mode, setMode] = useState(pendingInvite ? 'join' : 'invite') const [inviteCode, setInviteCode] = useState(null) - const [joinCode, setJoinCode] = useState('') + const [joinCode, setJoinCode] = useState(pendingInvite ?? '') const [isJoining, setIsJoining] = useState(false) useEffect(() => { @@ -115,6 +118,7 @@ const CircleSetupView = () => { try { const resp = await JoinCircle(joinCode.trim()) if (resp.ok) { + clearPendingInvite() showAcknowledgment( "Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.", 'Request Sent', @@ -308,7 +312,11 @@ const CircleSetupView = () => { level='body-sm' color='neutral' underline='hover' - onClick={() => setMode('invite')} + onClick={() => { + clearPendingInvite() + setJoinCode('') + setMode('invite') + }} > Back diff --git a/src/views/Settings/CircleSettings.jsx b/src/views/Settings/CircleSettings.jsx index b83885f..370514e 100644 --- a/src/views/Settings/CircleSettings.jsx +++ b/src/views/Settings/CircleSettings.jsx @@ -1,4 +1,5 @@ -import { Delete, Refresh } from '@mui/icons-material' +import { Share } from '@capacitor/share' +import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material' import { Box, Button, @@ -9,15 +10,17 @@ import { Input, Option, Select, - Typography + Typography, } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import moment from 'moment' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' + import { useLocalization } from '../../contexts/LocalizationContext' import { useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' +import { apiClient } from '../../utils/ApiClient' import { AcceptCircleMemberRequest, DeleteCircleMember, @@ -115,6 +118,36 @@ const CircleSettings = () => { } }, [circleMembers, userProfile]) + const inviteCode = userCircles[0]?.invite_code + const apiURL = new URL(apiClient.getApiURL(), window.location.origin) + const inviteOrigin = + apiURL.hostname === 'api.donetick.com' + ? 'https://app.donetick.com' + : `${apiURL.origin}${apiURL.pathname.replace(/\/api\/v1\/?$/, '')}` + const inviteLink = inviteCode + ? `${inviteOrigin.replace(/\/$/, '')}/circle/join?code=${encodeURIComponent(inviteCode)}` + : '' + + const shareInvite = async () => { + const circleName = userCircles[0]?.name || 'my Circle' + + try { + await Share.share({ + title: `Join ${circleName} on Donetick`, + text: `I'd like to invite you to join ${circleName} on Donetick.`, + url: inviteLink, + dialogTitle: 'Share Circle invite', + }) + } catch (error) { + if (error?.message?.toLowerCase().includes('cancel')) return + await navigator.clipboard.writeText(inviteLink) + showNotification({ + type: 'success', + message: 'Invite link copied to clipboard', + }) + } + } + if (!userProfile) { return } @@ -128,84 +161,85 @@ const CircleSettings = () => { link below. You'll receive a notification below when someone requests to join your Circle. - + + {userCircles[0]?.userRole === 'member' ? `You part of ${userCircles[0]?.name} ` : `You circle code is:`} - + - - - {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && ( - )} - + + {userCircles.length > 0 && + userCircles[0]?.userRole === 'member' && ( + + )} + + Circle Members {circleMembers.map(member => ( @@ -227,8 +261,7 @@ const CircleSettings = () => { ) : ( - Request to join{' '} - {fmt.date(member.updatedAt)} + Request to join {fmt.date(member.updatedAt)} )}