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' && (
}
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 => (
@@ -227,8 +261,7 @@ const CircleSettings = () => {
) : (
- Request to join{' '}
- {fmt.date(member.updatedAt)}
+ Request to join {fmt.date(member.updatedAt)}
)}