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 282c777..1957455 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -9,6 +9,8 @@ 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
@@ -78,7 +80,12 @@ const handleUrlOpen = (url, isColdStart = false) => {
(parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join')
if (isCircleInvite) {
- routerNavigate(`/circle/join${parsedUrl.search}`)
+ 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
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 03b27d1..370514e 100644
--- a/src/views/Settings/CircleSettings.jsx
+++ b/src/views/Settings/CircleSettings.jsx
@@ -1,5 +1,5 @@
import { Share } from '@capacitor/share'
-import { Delete, IosShare, Refresh } from '@mui/icons-material'
+import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material'
import {
Box,
Button,
@@ -161,89 +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:`}
-
+
-
- }
- sx={{ ml: 1 }}
- onClick={shareInvite}
- >
- Share Invite
-
-
- {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
}
onClick={() => {
- showConfirmation(
- 'Are you sure you want to leave your circle?',
- 'Leave Circle',
- () => {
- LeaveCircle(userCircles[0]?.id).then(resp => {
- if (resp.ok) {
- showNotification({
- type: 'success',
- message: 'Left circle successfully',
- })
- } else {
- showNotification({
- type: 'error',
- message: 'Failed to leave circle',
- })
- }
- })
- },
- 'Leave',
- 'Cancel',
- 'danger',
- )
+ navigator.clipboard.writeText(userCircles[0]?.invite_code)
+ showNotification({
+ type: 'success',
+ message: 'Code copied to clipboard',
+ })
}}
>
- Leave Circle
+ Copy Code
- )}
-
+ }
+ onClick={shareInvite}
+ >
+ Share Invite
+
+ {userCircles.length > 0 &&
+ userCircles[0]?.userRole === 'member' && (
+
+ )}
+
+
Circle Members
{circleMembers.map(member => (