23 Commits

Author SHA1 Message Date
Mo Tarbin
c474041c7c Release 1.2.45
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-10 01:40:04 -04:00
Mohamad Tarbin
c649e051d8 Merge pull request #201 from donetick/search-improvments
Search improvments
2026-08-10 01:19:43 -04:00
Mohamad Tarbin
207888affe Merge pull request #198 from donetick/share-with
Share with for circle
2026-08-10 01:19:33 -04:00
Mo Tarbin
5f0c5cdc5d Implement onboarding flow and circle invite handling with new utilities 2026-08-10 01:14:31 -04:00
Mo Tarbin
5b7594ac64 Add apple-app-site-association and assetlinks configuration for app linking 2026-08-10 01:13:06 -04:00
Mo Tarbin
90a38bf091 Add Capacitor Share integration and implement Circle invite sharing feature 2026-08-10 01:13:06 -04:00
Mo Tarbin
eecfc98ebc Refactor ArchivedTasks component: reorder imports, adjust notification hooks, and clean up unused code 2026-08-10 00:55:31 -04:00
Mohamad Tarbin
6428239be2 Merge pull request #200 from donetick/0809-fixes
Fix autocomplete issues and enhance task input handling
2026-08-10 00:54:52 -04:00
Mo Tarbin
4c6e244069 fix: clean up unused imports and simplify component structure in AddTaskModal 2026-08-10 00:53:40 -04:00
Mo Tarbin
af926b033e improve keyboard shortcuts handling 2026-08-10 00:19:57 -04:00
Mo Tarbin
b7bdfa7885 Implement global search feature with context, UI components, and routing updates 2026-08-10 00:16:38 -04:00
Mo Tarbin
309fd32d50 Enhance task input highlighting with smooth appearance animation and improved CSS keyframes for better visual feedback. 2026-08-09 13:19:32 -04:00
Mo Tarbin
2442b0c441 Fix: autocomplete not visible. refactor: streamline task input handling and improve smart input suggestions and and it's performance 2026-08-09 13:19:16 -04:00
Mohamad Tarbin
5d8095bd9c Merge pull request #199 from donetick/0809-fixes
0809 fixes
2026-08-09 13:17:08 -04:00
Mo Tarbin
1a8aeb84cc fix: if user type @Ni and then click Nick then what display was NiNick
fix: if user select assignee keep then if we don't see assignee in the title
2026-08-09 12:28:12 -04:00
Mo Tarbin
b2529a0091 fix autocomplete in addtaskmodal not displaying 2026-08-09 12:22:30 -04:00
Mo Tarbin
841f83d6ae Enhance file upload functionality with document scanning support and improved error handling 2026-08-09 12:00:30 -04:00
Mo Tarbin
731c46b26a Refactor AssigneePickerField to improve value handling and enhance UI/UX for assignee selection 2026-08-09 11:50:51 -04:00
Mo Tarbin
612924e7c3 Add theme background support for light and dark modes in StatusBarManager and ThemeContext
Fix: https://github.com/donetick/donetick/issues/759
2026-08-09 11:50:51 -04:00
Mo Tarbin
78760ceebb Refactor status bar theme handling, attempt to remove the black spaces 2026-08-09 11:50:51 -04:00
Mo Tarbin
e05aa2d169 JoinCircleView: enhance UI/UX with improved messaging, animations, and layout adjustments 2026-08-08 16:01:10 -04:00
Mo Tarbin
1ddf2539e0 Improve circle joining experiance 2026-08-08 13:40:21 -04:00
Mo Tarbin
83a5760959 Enhance error reporting functionality: add bug report option in settings, update error report modal, and refine feedback descriptions 2026-08-07 20:25:06 -04:00
59 changed files with 2409 additions and 658 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 59
versionName "1.2.38"
versionCode 66
versionName "1.2.45"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -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')

View File

@@ -30,6 +30,17 @@
<data android:scheme="donetick" />
</intent-filter>
<!-- Verified App Link for Circle invites hosted by Donetick Cloud. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="app.donetick.com"
android:pathPrefix="/circle/join" />
</intent-filter>
<!-- NFC NDEF dispatch: open app directly when a donetick:// tag is scanned -->
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />

View File

@@ -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')

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.38;
MARKETING_VERSION = 1.2.45;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -485,12 +485,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.38;
MARKETING_VERSION = 1.2.45;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -504,12 +504,12 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.38;
MARKETING_VERSION = 1.2.45;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -527,12 +527,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.38;
MARKETING_VERSION = 1.2.45;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.donetick.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.donetick.app</string>

View File

@@ -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'

View File

@@ -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

14
package-lock.json generated
View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.38",
"version": "1.2.45",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -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",

View File

@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "6UJJ78R3BS.com.donetick.app",
"paths": ["/circle/join*"]
}
]
}
}

View File

@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "6UJJ78R3BS.com.donetick.app",
"paths": ["/circle/join*"]
}
]
}
}

View File

@@ -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"
]
}
}
]

8
public/_headers Normal file
View File

@@ -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

1
public/_redirects Normal file
View File

@@ -0,0 +1 @@
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200

View File

@@ -20,6 +20,7 @@
"logout": "Logout",
"version": "Version",
"navigation": {
"search": "Search",
"allTasks": "All Tasks",
"archived": "Archived",
"things": "Things",

View File

@@ -171,7 +171,11 @@
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
"description": "Tell us how Donetick is working for you or request a feature."
},
"bugReport": {
"title": "Report a Bug",
"description": "Something not working right? Send us the details along with a technical snapshot."
}
}
}

View File

@@ -16,6 +16,7 @@ import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
import { useResource } from './queries/ResourceQueries'
import { GlobalSearchProvider } from './search/GlobalSearchContext'
import { recordRoute } from './service/DiagnosticsSession'
import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
@@ -42,8 +43,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 +96,8 @@ const AppContent = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [needRefresh])
if (isRedirectingToOnboarding) return null
return (
<div>
<ImpersonateUserProvider>
@@ -145,7 +148,9 @@ function App() {
<AuthProvider>
<SSEProvider>
<AppContent />
<GlobalSearchProvider>
<AppContent />
</GlobalSearchProvider>
</SSEProvider>
</AuthProvider>
</div>

View File

@@ -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 <App /> 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.

View File

@@ -5,3 +5,8 @@ import tailwindConfig from '/tailwind.config.mjs'
export const { theme: THEME } = resolveConfig(tailwindConfig)
export const COLORS = THEME.colors
export const THEME_BACKGROUND = {
dark: '#000000',
light: '#FFFFFF',
}

View File

@@ -22,6 +22,8 @@ export const Z_INDEX = {
MODAL_BACKDROP: 2000,
MODAL_CONTENT: 2001,
MODAL_CLOSE_BUTTON: 2002,
// Popups that must float above open modals (portaled to document.body)
MODAL_POPOVER: 2100,
TOAST: 3000,
// Critical System UI (9000-9999)

View File

@@ -13,6 +13,7 @@ import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
import GlobalSearchPage from '../search/GlobalSearchPage'
import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
import LoginSettings from '../views/Authorization/LoginSettings'
@@ -131,6 +132,10 @@ const Router = createBrowserRouter([
path: '/chores',
element: <MyChores />,
},
{
path: '/search',
element: <GlobalSearchPage />,
},
{
path: '/archived',
element: <ArchivedTasks />,

View File

@@ -1,8 +1,9 @@
import { COLORS } from '@/constants/theme'
import { CssBaseline } from '@mui/joy'
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types'
import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
const primaryColor = 'cyan'
const shades = [
'50',
@@ -34,6 +35,9 @@ const theme = extendTheme({
colorSchemes: {
light: {
palette: {
background: {
body: THEME_BACKGROUND.light,
},
primary: primaryPalette,
success: {
50: '#f3faf7',
@@ -75,6 +79,9 @@ const theme = extendTheme({
},
dark: {
palette: {
background: {
body: THEME_BACKGROUND.dark,
},
primary: primaryPalette,
},
},

View File

@@ -1,14 +1,15 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({
entityType = 'chore_attachment',
entityId,
draftId,
entityId,
entityType = 'chore_attachment',
} = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
@@ -19,28 +20,36 @@ export const useFileUpload = ({
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
// Only images go through compression — anything else (PDFs, docs)
// would be destroyed by re-encoding it as a JPEG.
let fileToUpload = file
if (file.type?.startsWith('image/')) {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(
file,
compressionOptions,
)
fileToUpload = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('file', fileToUpload)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', String(entityId))
if (draftId) formData.append('draftId', draftId)
@@ -62,7 +71,7 @@ export const useFileUpload = ({
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
message: 'File uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
@@ -74,7 +83,7 @@ export const useFileUpload = ({
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
message: 'Failed to upload file.',
})
return null
}
@@ -91,7 +100,7 @@ export const useFileUpload = ({
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
message: 'An error occurred while processing the file.',
})
return null
}

View File

@@ -1,6 +1,8 @@
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
@@ -11,6 +13,9 @@ const ALLOWED_PATHS = [
'/login/settings',
'/privacy',
'/terms',
// An invite link is a legitimate first launch: the join view explains itself
// and routes to sign-in, so onboarding must not swallow the code.
'/circle/join',
]
const isAllowed = pathname =>
@@ -23,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

View File

@@ -1,5 +1,6 @@
import { useColorScheme } from '@mui/joy'
import { useEffect } from 'react'
import statusBarManager from '../utils/StatusBarManager'
/**
@@ -35,10 +36,7 @@ export const useStatusBar = () => {
// Update the status bar with the resolved theme
await statusBarManager.updateResolvedTheme(resolvedTheme)
// Also update the base theme for future reference
await statusBarManager.setTheme(mode)
// Notify any custom listeners
statusBarManager.notifyThemeChange(resolvedTheme)
}

View File

@@ -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,11 +92,18 @@ 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
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
// No session, nothing to sync — and a 401 here would force a logout that
// hard-navigates signed-out visitors (invite links) away to /login.
if (!localStorage.getItem('token')) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {

View File

@@ -29,6 +29,7 @@ export const useAllUsers = () => {
export const useCircleMembers = () => {
const queryClient = useQueryClient()
const token = localStorage.getItem('token')
const { data, error, isLoading } = useQuery({
queryKey: ['allCircleMembers'],
@@ -46,6 +47,10 @@ export const useCircleMembers = () => {
return { res: [] }
}
},
// NavBar's avatar mounts this on every route, including the signed-out
// ones. Without the gate the 401 tips ApiClient into a forced logout that
// hard-navigates to /login — which is what used to eat circle invites.
enabled: !!token,
})
const handleRefetch = () => {

View File

@@ -0,0 +1,192 @@
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQueryClient } from '@tanstack/react-query'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { offlineDB } from '../utils/OfflineDB'
import { isParentUser } from '../utils/UserHelpers'
import GlobalSearchPalette from './GlobalSearchPalette'
import { getSearchProviders } from './searchProviders'
const GlobalSearchContext = createContext(null)
const BLOCKED_ROUTES = [
'/login',
'/signup',
'/welcome',
'/onboarding',
'/get-started',
'/ready',
]
const unwrap = value => (Array.isArray(value) ? value : value?.res || [])
const uniqueBy = (items, getId) => [
...new Map(
items.filter(Boolean).map(item => [String(getId(item)), item]),
).values(),
]
export const GlobalSearchProvider = ({ children }) => {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:768px)')
const [isOpen, setIsOpen] = useState(false)
const [initialQuery, setInitialQuery] = useState('')
const [documents, setDocuments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const loadDocuments = useCallback(async () => {
setIsLoading(true)
try {
const cachedChores = queryClient
.getQueriesData({ queryKey: ['chores'] })
.flatMap(([, data]) => unwrap(data))
const cachedHistory = [
...queryClient.getQueriesData({ queryKey: ['choresHistory'] }),
...queryClient.getQueriesData({ queryKey: ['choreHistory'] }),
].flatMap(([, data]) => unwrap(data))
const [
offlineChores,
offlineHistory,
offlineProjects,
offlineLabels,
offlineMembers,
offlineProfile,
] = await Promise.all([
offlineDB.getChores(true).catch(() => []),
offlineDB.getHistoryByDays(365).catch(() => []),
offlineDB.getKV('projects').catch(() => []),
offlineDB.getKV('labels').catch(() => []),
offlineDB.getKV('circle_members').catch(() => []),
offlineDB.getKV('user_profile').catch(() => null),
])
const projects = uniqueBy(
[
...unwrap(queryClient.getQueryData(['projects'])),
...unwrap(offlineProjects),
],
item => item.id,
)
const labels = uniqueBy(
[
...unwrap(queryClient.getQueryData(['labels'])),
...unwrap(offlineLabels),
],
item => item.id,
)
const members = uniqueBy(
[
...unwrap(queryClient.getQueryData(['allCircleMembers'])),
...unwrap(offlineMembers),
],
item => item.userId,
)
const chores = uniqueBy(
[...cachedChores, ...unwrap(offlineChores)],
item => item.id,
)
const history = uniqueBy(
[...cachedHistory, ...unwrap(offlineHistory)],
item => item.id,
)
const profile =
queryClient
.getQueriesData({ queryKey: ['userProfile'] })
.find(([, data]) => data)?.[1] || offlineProfile
const sources = {
chores,
history,
projects,
labels,
members,
isParent: isParentUser(profile),
choresById: new Map(chores.map(item => [String(item.id), item])),
projectsById: new Map(projects.map(item => [String(item.id), item])),
membersById: new Map(members.map(item => [String(item.userId), item])),
}
const nextDocuments = getSearchProviders().flatMap(provider => {
try {
return provider.getDocuments(sources) || []
} catch (error) {
console.warn(`Search provider ${provider.id} failed`, error)
return []
}
})
setDocuments(nextDocuments)
} finally {
setIsLoading(false)
}
}, [queryClient])
const openSearch = useCallback(
(query = '') => {
if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route)))
return
if (isMobile) {
loadDocuments()
navigate('/search', { state: { initialQuery: query } })
return
}
setInitialQuery(query)
setIsOpen(true)
loadDocuments()
},
[isMobile, loadDocuments, location.pathname, navigate],
)
const closeSearch = useCallback(() => setIsOpen(false), [])
useEffect(() => {
const onKeyDown = event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
isOpen ? closeSearch() : openSearch()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closeSearch, isOpen, openSearch])
const value = useMemo(
() => ({
closeSearch,
documents,
isLoading,
loadDocuments,
openSearch,
}),
[closeSearch, documents, isLoading, loadDocuments, openSearch],
)
return (
<GlobalSearchContext.Provider value={value}>
{children}
{isOpen && (
<GlobalSearchPalette
documents={documents}
initialQuery={initialQuery}
isLoading={isLoading}
onClose={closeSearch}
/>
)}
</GlobalSearchContext.Provider>
)
}
export const useGlobalSearch = () => {
const context = useContext(GlobalSearchContext)
if (!context)
throw new Error('useGlobalSearch must be used inside GlobalSearchProvider')
return context
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useGlobalSearch } from './GlobalSearchContext'
import GlobalSearchPalette from './GlobalSearchPalette'
const GlobalSearchPage = () => {
const location = useLocation()
const navigate = useNavigate()
const { documents, isLoading, loadDocuments } = useGlobalSearch()
useEffect(() => {
loadDocuments()
}, [loadDocuments])
const handleClose = () => {
if (window.history.state?.idx > 0) navigate(-1)
else navigate('/chores', { replace: true })
}
return (
<GlobalSearchPalette
documents={documents}
initialQuery={location.state?.initialQuery || ''}
isLoading={isLoading}
onClose={handleClose}
presentation='page'
/>
)
}
export default GlobalSearchPage

View File

@@ -0,0 +1,468 @@
import {
AddRounded,
CheckCircleOutline,
FolderOutlined,
HistoryRounded,
InboxOutlined,
LabelOutlined,
PersonOutline,
SearchRounded,
SettingsOutlined,
} from '@mui/icons-material'
import {
Box,
Chip,
CircularProgress,
Divider,
Input,
List,
ListItemButton,
ListItemContent,
ListItemDecorator,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import PropTypes from 'prop-types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import AppModal from '../components/common/AppModal'
const RECENTS_KEY = 'donetick.globalSearch.recents'
const GROUPS = [
'tasks',
'history',
'projects',
'labels',
'people',
'settings',
'actions',
]
const GROUP_LABELS = {
tasks: 'Tasks',
history: 'Notes',
projects: 'Projects',
labels: 'Labels',
people: 'People',
settings: 'Settings',
actions: 'Quick actions',
}
const ICONS = {
tasks: <CheckCircleOutline />,
history: <HistoryRounded />,
projects: <FolderOutlined />,
labels: <LabelOutlined />,
people: <PersonOutline />,
settings: <SettingsOutlined />,
actions: <AddRounded />,
}
const QUICK_ACTIONS = [
{
id: 'action:create',
provider: 'actions',
title: 'Create a task',
subtitle: 'Quick action',
route: '/chores/create',
},
{
id: 'action:tasks',
provider: 'actions',
title: 'View all tasks',
subtitle: 'Navigation',
route: '/chores',
},
{
id: 'action:archived',
provider: 'actions',
title: 'View archived tasks',
subtitle: 'Navigation',
route: '/archived',
},
{
id: 'action:settings',
provider: 'actions',
title: 'Open settings',
subtitle: 'Navigation',
route: '/settings',
},
]
const readRecents = () => {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
} catch {
return []
}
}
const saveRecent = result => {
if (result.provider === 'actions') return
const recent = {
id: result.id,
provider: result.provider,
route: result.route,
title: result.title,
subtitle: result.subtitle,
}
localStorage.setItem(
RECENTS_KEY,
JSON.stringify(
[recent, ...readRecents().filter(item => item.id !== result.id)].slice(
0,
6,
),
),
)
}
const Highlight = ({ query, text }) => {
if (!text || !query.trim()) return text || null
const words = query.trim().split(/\s+/).filter(Boolean)
const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
if (!escaped.length) return text
const pattern = new RegExp(`(${escaped.join('|')})`, 'ig')
const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i')
return String(text)
.split(pattern)
.map((part, index) =>
isMatch.test(part) ? (
<Box
component='mark'
key={index}
sx={{ bgcolor: 'warning.softBg', color: 'inherit', borderRadius: 2 }}
>
{part}
</Box>
) : (
part
),
)
}
const SearchContainer = ({ children, onClose, presentation }) => {
if (presentation === 'page') {
return (
<Box
component='main'
sx={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100dvh - 56px)',
minHeight: 0,
overflow: 'hidden',
bgcolor: 'background.body',
}}
>
{children}
</Box>
)
}
return (
<AppModal
open
onClose={onClose}
disableRestoreFocus
title='Search'
size='lg'
maxHeight='min(720px, calc(100dvh - 48px))'
contentSx={{
p: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
sx={{ height: 'min(720px, calc(100dvh - 48px))' }}
>
{children}
</AppModal>
)
}
const GlobalSearchPalette = ({
documents,
initialQuery,
isLoading,
onClose,
presentation = 'modal',
}) => {
const navigate = useNavigate()
const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus())
}, [])
const [query, setQuery] = useState(initialQuery || '')
const [selectedIndex, setSelectedIndex] = useState(0)
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
const searchIndexes = useMemo(
() =>
new Map(
GROUPS.filter(group => group !== 'actions').map(group => [
group,
new Fuse(
documents.filter(item => item.provider === group),
{
threshold: 0.38,
distance: 120,
ignoreLocation: true,
includeScore: true,
keys:
group === 'history'
? [{ name: 'body', weight: 1 }]
: [
{ name: 'title', weight: 0.5 },
{ name: 'keywords', weight: 0.25 },
{ name: 'body', weight: 0.17 },
{ name: 'subtitle', weight: 0.08 },
],
},
),
]),
),
[documents],
)
const results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase()
if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item]))
const recentResults = recents
.map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id))
return [...recentResults, ...QUICK_ACTIONS]
}
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
(searchIndexes.get(group)?.search(normalized, { limit: 7 }) || [])
.map(match => {
const title = match.item.title?.toLocaleLowerCase() ?? ''
let score = match.score ?? 1
if (group !== 'history') {
if (title === normalized) {
score -= 1
} else if (title.startsWith(normalized)) {
score -= 0.15
} else if (title.includes(normalized)) {
score -= 0.08
}
}
return { ...match.item, score }
})
.sort((a, b) => a.score - b.score),
)
grouped.push({
id: 'action:filter-tasks',
provider: 'actions',
title: `Show tasks matching “${query.trim()}`,
subtitle: 'Filter the task list',
route: `/chores?search=${encodeURIComponent(query.trim())}`,
})
return grouped
}, [documents, query, recents, searchIndexes])
useEffect(() => {
selectedResultRef.current?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}, [selectedIndex, results])
const selectResult = result => {
saveRecent(result)
navigate(result.route)
if (presentation === 'modal') onClose()
}
const onInputKeyDown = event => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex(index => Math.max(index - 1, 0))
} else if (event.key === 'Enter' && results[selectedIndex]) {
event.preventDefault()
selectResult(results[selectedIndex])
} else if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
}
return (
<SearchContainer onClose={onClose} presentation={presentation}>
<Box sx={{ p: { xs: 1.5, sm: 2 } }}>
<Input
autoFocus
slotProps={{
input: {
ref: focusInputRef,
'aria-label':
'Search tasks, history, projects, labels and settings',
},
}}
value={query}
onChange={event => {
setQuery(event.target.value)
setSelectedIndex(0)
}}
onKeyDown={onInputKeyDown}
placeholder='Search Donetick'
startDecorator={<SearchRounded />}
endDecorator={
isLoading ? (
<CircularProgress size='sm' />
) : presentation === 'modal' ? (
<Chip size='sm' variant='outlined'>
Esc
</Chip>
) : null
}
sx={{
'--Input-minHeight': '48px',
fontSize: 'md',
borderRadius: 'lg',
}}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', mt: 1, px: 0.5 }}
>
Searching content available on this device
</Typography>
</Box>
<Divider />
<Box
sx={{
overflowY: 'auto',
flex: 1,
pb: 'var(--safe-area-inset-bottom, 0px)',
}}
>
{!isLoading && query.trim() && results.length === 1 && (
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
<InboxOutlined
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
/>
<Typography level='title-md'>No direct matches</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
You can still filter the task list with this search.
</Typography>
</Box>
)}
<List aria-live='polite' sx={{ px: 1, py: 1 }}>
{results.map((result, index) => {
const hasQuery = Boolean(query.trim())
const showHeading = hasQuery
? index === 0 || result.provider !== results[index - 1].provider
: index === 0 ||
(result.provider === 'actions' &&
results[index - 1].provider !== 'actions')
return (
<Box key={result.id}>
{showHeading && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
fontWeight: 'lg',
px: 1.5,
pt: index ? 2 : 0.5,
pb: 0.5,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{!query.trim() && result.provider !== 'actions'
? 'Recent'
: GROUP_LABELS[result.provider]}
</Typography>
)}
<ListItemButton
ref={index === selectedIndex ? selectedResultRef : null}
selected={index === selectedIndex}
onMouseMove={() => setSelectedIndex(index)}
onClick={() => selectResult(result)}
sx={{
borderRadius: 'md',
py: 1.1,
alignItems: 'flex-start',
}}
>
<ListItemDecorator
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
>
{ICONS[result.provider]}
</ListItemDecorator>
<ListItemContent>
<Typography
level='title-sm'
sx={{ overflowWrap: 'anywhere' }}
>
<Highlight query={query} text={result.title} />
</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.secondary' }}
noWrap
>
{[result.subtitle, result.body]
.filter(Boolean)
.join(' · ')}
</Typography>
</ListItemContent>
</ListItemButton>
</Box>
)
})}
</List>
</Box>
<Divider />
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
gap: 2,
px: 2,
py: 1,
color: 'text.tertiary',
}}
>
<Typography level='body-xs'> Navigate</Typography>
<Typography level='body-xs'> Open</Typography>
<Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim()
? `${Math.max(0, results.length - 1)} results`
: 'Type to search'}
</Typography>
</Box>
</SearchContainer>
)
}
SearchContainer.propTypes = {
children: PropTypes.node.isRequired,
onClose: PropTypes.func.isRequired,
presentation: PropTypes.oneOf(['modal', 'page']).isRequired,
}
Highlight.propTypes = {
query: PropTypes.string.isRequired,
text: PropTypes.string,
}
GlobalSearchPalette.propTypes = {
documents: PropTypes.arrayOf(PropTypes.object).isRequired,
initialQuery: PropTypes.string,
isLoading: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
presentation: PropTypes.oneOf(['modal', 'page']),
}
export default GlobalSearchPalette

View File

@@ -0,0 +1,182 @@
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const HISTORY_STATUS = {
0: 'in progress',
1: 'completed',
2: 'skipped',
3: 'pending approval',
4: 'rejected',
5: 'missed',
6: 'rescheduled',
}
const SETTINGS = [
['profile', 'Profile', 'Name, avatar and personal details'],
['circle', 'Circle', 'Members and household settings', true],
['account', 'Account', 'Subscription and account management', true],
['subaccounts', 'Subaccounts', 'Manage child accounts'],
['notifications', 'Notifications', 'Reminders and notification preferences'],
['mfa', 'Multi-factor authentication', 'Secure your account', true],
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
['storage', 'Storage', 'Files, backups and device storage'],
['sidepanel', 'Side panel', 'Customize navigation'],
['theme', 'Appearance', 'Theme, dark mode and colors'],
['localization', 'Language and region', 'Language, dates and time formats'],
[
'advanced',
'Advanced settings',
'Offline support, webhooks and application behavior',
],
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
]
const providers = []
export const registerSearchProvider = provider => {
if (!provider?.id || typeof provider.getDocuments !== 'function') {
throw new Error('A search provider needs an id and getDocuments function')
}
const existing = providers.findIndex(item => item.id === provider.id)
if (existing >= 0) providers.splice(existing, 1, provider)
else providers.push(provider)
return () => {
const index = providers.indexOf(provider)
if (index >= 0) providers.splice(index, 1)
}
}
export const getSearchProviders = () => [...providers]
const document = (provider, item) => ({ provider, ...item })
registerSearchProvider({
id: 'tasks',
getDocuments: ({ chores, membersById, projectsById }) =>
chores.map(chore => {
const labels =
chore.labelsV2?.map(label => label.name).filter(Boolean) || []
const project = projectsById.get(String(chore.projectId))
const assignees = (chore.assignees || [])
.map(assignee => membersById.get(String(assignee.userId))?.displayName)
.filter(Boolean)
const description = stripHtml(chore.description)
return document('tasks', {
id: `task:${chore.id}`,
entityId: chore.id,
title: chore.name || 'Untitled task',
subtitle:
[project?.name, ...labels].filter(Boolean).join(' · ') || 'Task',
body: description,
keywords: [...labels, project?.name, ...assignees]
.filter(Boolean)
.join(' '),
route: `/chores/${chore.id}`,
updatedAt: chore.updatedAt || chore.createdAt,
})
}),
})
registerSearchProvider({
id: 'history',
getDocuments: ({ choresById, history, membersById }) =>
history.flatMap(entry => {
const note = stripHtml(entry.notes).trim()
if (!note) return []
const chore = choresById.get(String(entry.choreId))
const member = membersById.get(String(entry.completedBy))
return [
document('history', {
id: `history:${entry.id}`,
entityId: entry.id,
title: chore?.name || entry.choreName || 'Task note',
subtitle: [
member?.displayName,
entry.performedAt
? new Date(entry.performedAt).toLocaleDateString()
: null,
]
.filter(Boolean)
.join(' · '),
body: note,
keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`,
route: entry.choreId
? `/chores/${entry.choreId}/history`
: '/activities',
updatedAt: entry.performedAt || entry.updatedAt,
}),
]
}),
})
registerSearchProvider({
id: 'projects',
getDocuments: ({ projects }) =>
projects.map(project =>
document('projects', {
id: `project:${project.id}`,
entityId: project.id,
title: project.name || 'Untitled project',
subtitle: 'Project',
body: stripHtml(project.description),
keywords: 'folder project',
route: `/chores?project=${encodeURIComponent(project.id)}`,
updatedAt: project.updatedAt,
}),
),
})
registerSearchProvider({
id: 'labels',
getDocuments: ({ labels }) =>
labels.map(label =>
document('labels', {
id: `label:${label.id}`,
entityId: label.id,
title: label.name || 'Untitled label',
subtitle: 'Label',
keywords: 'tag label',
route: '/labels',
color: label.color,
}),
),
})
registerSearchProvider({
id: 'people',
getDocuments: ({ members }) =>
members.map(member =>
document('people', {
id: `person:${member.userId}`,
entityId: member.userId,
title: member.displayName || member.username || 'Circle member',
subtitle: 'Circle member',
keywords: `${member.username || ''} person member assignee`,
route: '/chores',
}),
),
})
registerSearchProvider({
id: 'settings',
getDocuments: ({ isParent }) =>
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
([id, title, description]) =>
document('settings', {
id: `setting:${id}`,
entityId: id,
title,
subtitle: 'Settings',
body: description,
keywords: `preferences configuration ${id}`,
route: `/settings/${id}`,
}),
),
})

View File

@@ -113,6 +113,9 @@ export const collectErrorReport = async ({ error, errorInfo, reportId }) => {
return {
reportId: reportId ?? newReportId(),
occurredAt: new Date().toISOString(),
// No error means the user came here deliberately from settings rather than
// off the back of a crash — same diagnostics, different story to tell.
kind: error ? 'crash' : 'bug',
error: describeError(error, errorInfo),
runtime: describeRuntime(),
app: context,
@@ -137,7 +140,11 @@ export const formatErrorReport = report => {
`Report ID: ${report.reportId}`,
`Time: ${report.occurredAt}`,
'',
`Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
// A user-initiated report has no throw behind it; "Error: Unknown" would
// only be noise in the panel the user is being asked to read.
report.kind === 'bug'
? 'Reported manually (no crash)'
: `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
error.status
? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim()
: null,
@@ -210,11 +217,14 @@ export const formatErrorReport = report => {
* leaves infrastructure they control, and they see it before it is published.
*/
export const buildErrorIssueUrl = ({ description, report }) => {
const title = `[crash] ${
report.error.message?.slice(0, 80) ||
report.error.name ||
'Unexpected error'
}`
const isBug = report.kind === 'bug'
const title = isBug
? `[bug] ${description?.trim().slice(0, 80) || 'Reported from the app'}`
: `[crash] ${
report.error.message?.slice(0, 80) ||
report.error.name ||
'Unexpected error'
}`
const body = [
'### What happened',
description?.trim() || '_no description provided_',
@@ -241,7 +251,7 @@ export const submitErrorReport = async ({
}) => {
const payload = {
source: 'donetick-app',
kind: 'error-report',
kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
reportId: report.reportId,
description: description?.trim() || null,
contactEmail: contactEmail?.trim() || null,

View File

@@ -155,6 +155,21 @@ class ApiClient {
return
}
// An expired session on an invite link would otherwise drop the code on the
// way to /login. Stash it first so sign-in returns to the join.
try {
const { pathname, search } = window.location
if (pathname === '/circle/join') {
const code = new URLSearchParams(search).get('code')
if (code) {
const { setPendingInvite } = await import('./PendingInvite')
setPendingInvite(code)
}
}
} catch (e) {
console.error('Error preserving pending invite on logout', e)
}
await clearAllTokens()
try {
await offlineDB.clearAll()

17
src/utils/FileConvert.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Turns an image source the scanners produce — a base64 data URI on iOS/web,
* a Capacitor localhost URL on Android — into a File the upload endpoint
* accepts. Both forms are fetchable, so one path covers them.
*/
export async function imageSourceToFile(source, fileName = 'scan.jpg') {
if (!source) return null
try {
const response = await fetch(source)
const blob = await response.blob()
const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
return new File([blob], fileName, { type })
} catch (e) {
console.error('[FileConvert] failed to convert image source:', e)
return null
}
}

View File

@@ -0,0 +1,46 @@
import Cookies from 'js-cookie'
// A circle invite link is often the very first thing a new user opens, so the
// code has to survive the trip through login/signup (including OAuth, which
// leaves and re-enters the app) and be replayed once a session exists.
const INVITE_KEY = 'pending_circle_invite'
const REDIRECT_COOKIE = 'ca_redirect'
// `auto=1` tells the join view this visit is the return leg of an auth
// round-trip, so it can submit the request instead of asking a second time.
export const joinCirclePath = code =>
`/circle/join?code=${encodeURIComponent(code)}&auto=1`
export const setPendingInvite = code => {
if (!code) return
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 })
}
export const getPendingInvite = () => {
try {
return localStorage.getItem(INVITE_KEY)
} catch {
return null
}
}
export const clearPendingInvite = () => {
try {
localStorage.removeItem(INVITE_KEY)
} catch {
// Ignore unavailable storage during cleanup.
}
const redirect = Cookies.get(REDIRECT_COOKIE)
if (redirect?.startsWith('/circle/join')) {
Cookies.remove(REDIRECT_COOKIE)
}
}

View File

@@ -2,6 +2,8 @@ import { Capacitor } from '@capacitor/core'
import { StatusBar, Style } from '@capacitor/status-bar'
import { SafeArea } from 'capacitor-plugin-safe-area'
import { THEME_BACKGROUND } from '@/constants/theme'
/**
* StatusBarManager - A utility class to handle status bar configuration
* following Capacitor best practices and theme-aware styling
@@ -52,17 +54,18 @@ class StatusBarManager {
this.currentTheme = theme
try {
let style = Style.Light // Default to light content (dark status bar)
if (theme === 'dark') {
style = Style.Dark // Dark content (light status bar)
} else if (theme === 'system') {
// For system theme, we need to detect the actual system preference
// Joy UI's useColorScheme will handle this, but we default to light
style = Style.Light
}
const resolvedTheme =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
await StatusBar.setStyle({ style })
await StatusBar.setBackgroundColor({
color: THEME_BACKGROUND[resolvedTheme],
})
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
} catch (error) {
console.error('StatusBarManager: Failed to set theme:', error)
@@ -162,15 +165,7 @@ class StatusBarManager {
async updateResolvedTheme(resolvedTheme) {
if (!this.isNativePlatform) return
try {
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
await StatusBar.setStyle({ style })
console.log(
`StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
)
} catch (error) {
console.error('StatusBarManager: Failed to update resolved theme:', error)
}
await this.setTheme(resolvedTheme)
}
/**

View File

@@ -12,12 +12,14 @@ import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { getPendingInvite } from '../../utils/PendingInvite'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import {
@@ -138,7 +140,15 @@ const LoginView = () => {
}, [])
useEffect(() => {
if (isAuthenticated && user) {
Navigate('/chores')
// An already-signed-in visitor who lands here from a deep link (a circle
// invite, for example) still has to end up where they were headed.
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl && redirectUrl !== '/') {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
}
}, [isAuthenticated, user, Navigate])
const handleSubmit = async e => {
@@ -416,9 +426,11 @@ const LoginView = () => {
<AuthShell
title={userProfile ? 'Welcome back' : 'Sign in'}
subtitle={
userProfile
? 'Pick up right where you left off.'
: 'Sign in to your account to continue.'
getPendingInvite()
? 'Sign in and well send your circle join request right after.'
: userProfile
? 'Pick up right where you left off.'
: 'Sign in to your account to continue.'
}
logoSize={0}
footer={<LegalLinks />}

View File

@@ -2,8 +2,11 @@ import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
import { signUp } from '../../utils/Fetcher'
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
import {
AuthPasswordField,
AuthSubmitButton,
@@ -25,28 +28,39 @@ const SignupView = () => {
const [displayNameError, setDisplayNameError] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
response.json().then(res => {
localStorage.setItem('token', res.token)
localStorage.setItem('token_expiry', res.expire)
const { login: authLogin } = useAuth()
// Sign-in goes through the auth context, not a bare fetch: it stores the
// refresh token and updates the provider's own state, so the rest of the app
// sees the new session without a reload.
const handleLogin = async (username, password) => {
const result = await authLogin({ username, password })
if (!result.success) {
showError({
title: 'Almost there',
message:
'Your account was created, but signing in failed. Please sign in.',
})
Navigate('/login')
return
}
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
// The "how did you hear about us" step (/heard-about) is
// temporarily skipped; new accounts go straight to circle setup.
// Re-enable by navigating to '/heard-about' again — that view
// already forwards to '/circle-setup' when done.
Navigate('/circle-setup', { replace: true })
})
} else {
console.log('Login failed', response)
// Someone who signed up from a circle invite is joining an existing
// circle, so sending them through "name your circle" is both a dead
// end for the invite and the wrong question.
const pendingInvite = getPendingInvite()
if (pendingInvite) {
Navigate(joinCirclePath(pendingInvite), { replace: true })
return
}
// Navigate('/login')
}
})
// The "how did you hear about us" step (/heard-about) is
// temporarily skipped; new accounts go straight to circle setup.
// Re-enable by navigating to '/heard-about' again — that view
// already forwards to '/circle-setup' when done.
Navigate('/circle-setup', { replace: true })
}
const handleSignUpValidation = () => {
// Reset errors before validation
@@ -132,7 +146,11 @@ const SignupView = () => {
return (
<AuthShell
title='Create your account'
subtitle='Track chores and tasks together, in one shared place.'
subtitle={
getPendingInvite()
? 'Create an account and well send your circle join request right after.'
: 'Track chores and tasks together, in one shared place.'
}
footer={<LegalLinks />}
logoSize={0}
>

View File

@@ -3,6 +3,7 @@ import {
ArrowDropDown,
AttachFile,
Delete,
DocumentScanner,
HorizontalRule,
Save,
UploadFile,
@@ -41,6 +42,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import DurationInput from '../../components/common/DurationInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import {
useArchiveChore,
useChore,
@@ -59,6 +61,7 @@ import {
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
import Priorities from '../../utils/Priorities.jsx'
@@ -173,6 +176,7 @@ const ChoreEdit = () => {
const { data: membersData, isLoading: isMemberDataLoading } =
useCircleMembers()
const { showError, showSuccess } = useNotification()
const { isNativeScanner, scanDocument } = useDocumentScanner()
const [userLabels, setUserLabels] = useState([])
@@ -671,6 +675,67 @@ const ChoreEdit = () => {
}
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
const uploadAttachmentFile = async file => {
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(file, 'chore_attachment_draft', {
draftId,
})
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
}
}
// Native only: the OS scanner returns a cropped, deskewed page which is a
// better attachment than a raw camera shot of the same document.
const handleScanAttachment = async () => {
const { cancelled, error, image } = await scanDocument()
if (cancelled) return
if (error || !image) {
showError({
title: 'Scan Failed',
message: error || 'Could not scan the document.',
})
return
}
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
if (!file) {
showError({
title: 'Scan Failed',
message: 'Could not read the scanned image.',
})
return
}
await uploadAttachmentFile(file)
}
const handleDelete = () => {
setConfirmModelConfig({
isOpen: true,
@@ -1109,62 +1174,39 @@ const ChoreEdit = () => {
))}
</Box>
)}
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={isUploadingAttachment ? null : <UploadFile />}
loading={isUploadingAttachment}
sx={{ alignSelf: 'flex-start' }}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(
file,
'chore_attachment_draft',
{ draftId },
)
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'flex-start' }}>
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={isUploadingAttachment ? null : <UploadFile />}
loading={isUploadingAttachment}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
e.target.value = ''
}
}}
/>
</Button>
await uploadAttachmentFile(file)
}}
/>
</Button>
{isNativeScanner && (
<Button
variant='outlined'
color='neutral'
size='sm'
startDecorator={<DocumentScanner />}
disabled={isUploadingAttachment}
onClick={handleScanAttachment}
>
Scan
</Button>
)}
</Box>
</Card>
</Box>
</Box>

View File

@@ -28,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
@@ -38,9 +39,9 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
@@ -93,7 +94,7 @@ const applyPendingArchivedState = async chores => {
const ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const { showSuccess, showError } = useNotification()
const { showError, showSuccess } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const queryClient = useQueryClient()
const unArchiveChore = useUnArchiveChore()
@@ -200,11 +201,11 @@ const ArchivedTasks = () => {
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
filteredData: finalChores,
hasActiveFilters,
setFilter,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
@@ -253,13 +254,6 @@ const ArchivedTasks = () => {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + F to focus search input
if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
searchInputRef.current?.focus()
return
}
// Ctrl/Cmd + S Toggle Multi-select mode
if (isHoldingCmdOrCtrl && event.key === 's') {
event.preventDefault()

View File

@@ -411,14 +411,29 @@ const MyChores = () => {
}
}, [searchInputFocus])
// A global-search result can hand a query back to the task list as a scoped filter.
useEffect(() => {
const query = searchParams.get('search')
if (query !== null) {
setSearchTerm(query.toLowerCase())
setSelectedCalendarDate(null)
clearActiveFilter()
clearQuickFilters()
}
// The setters above are intentionally applied only when URL search params change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Read and apply project from URL parameters
useEffect(() => {
if (!projects.length) return
const projectIdFromUrl = searchParams.get('project')
if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
const project = projectsWithDefault.find(
p => String(p.id) === projectIdFromUrl,
)
if (project) {
setSelectedProjectWithCache(project)
}
@@ -759,6 +774,11 @@ const MyChores = () => {
setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchInputFocus(0)
setSelectedCalendarDate(null)
if (searchParams.has('search')) {
const params = new URLSearchParams(searchParams)
params.delete('search')
setSearchParams(params, { replace: true })
}
}
const setSelectedChoreSectionWithCache = value => {

View File

@@ -1,21 +1,33 @@
import { CancelRounded } from '@mui/icons-material'
import { CancelRounded, SearchRounded } from '@mui/icons-material'
import { Box, Input } from '@mui/joy'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useGlobalSearch } from '../../../search/GlobalSearchContext'
const SearchBar = ({
value,
inputRef,
onChange,
onClose,
onFocus,
showKeyboardShortcuts,
inputRef,
value,
}) => {
const { openSearch } = useGlobalSearch()
const handleOpen = () => {
onFocus?.()
openSearch(value)
}
return (
<Input
slotProps={{ input: { ref: inputRef } }}
placeholder='Search'
slotProps={{ input: { ref: inputRef, readOnly: true } }}
placeholder='Search Donetick'
value={value}
onFocus={onFocus}
onFocus={handleOpen}
onMouseDown={event => {
event.preventDefault()
handleOpen()
}}
fullWidth
sx={{
mt: 1,
@@ -24,17 +36,29 @@ const SearchBar = ({
height: 24,
borderColor: 'text.disabled',
padding: 1,
cursor: 'pointer',
'& input': { cursor: 'pointer' },
}}
onChange={onChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
</Box>
}
endDecorator={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{value && (
<>
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
<CancelRounded onClick={onClose} />
<CancelRounded
aria-label='Clear task search'
onMouseDown={event => event.stopPropagation()}
onClick={event => {
event.stopPropagation()
onClose()
}}
/>
</>
)}
</Box>

View File

@@ -1,15 +1,15 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
export const useKeyboardShortcuts = ({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
choreSections,
openChoreSections,
filteredChores,
handlers,
isMultiSelectMode,
openChoreSections,
searchFilter,
searchTerm,
selectedChores,
}) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
@@ -35,10 +35,6 @@ export const useKeyboardShortcuts = ({
event.preventDefault()
handlers.onNavigateToCreate()
return
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
handlers.onFocusSearch()
return
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
event.preventDefault()
if (searchTerm?.length > 0) {

View File

@@ -1,162 +1,260 @@
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
import { Button } from '@mui/joy'
import { useState } from 'react'
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { JoinCircle } from '../../utils/Fetcher'
import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
import { authButtonSx } from '../Authorization/authStyles'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
import { CircleVignette } from '../Onboarding/OnboardingVignettes'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
animation: `joinCircleIn 520ms ${EASE} ${delay}ms both`,
'@keyframes joinCircleIn': {
from: { opacity: 0, transform: 'translateY(12px)' },
to: { opacity: 1, transform: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
})
const JoinCircleView = () => {
const { data: userProfile } = useUserProfile()
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
// Read the token rather than useAuth(): the provider's copy only updates
// through its own login(), so signup and the OAuth callback — which save
// tokens directly — would still look signed out here. The query hooks read
// storage the same way.
const isAuthenticated = !!localStorage.getItem('token')
const { showError } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [isJoining, setIsJoining] = useState(false)
let [searchParams, setSearchParams] = useSearchParams()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const code = searchParams.get('code')
// `auto=1` is on the link we send the user back to after they authenticate,
// and only there — someone who opens an invite while already signed in gets
// asked, not auto-joined.
const isReturningFromAuth = searchParams.get('auto') === '1'
const autoJoinAttempted = useRef(false)
const submitJoin = useCallback(() => {
setIsJoining(true)
JoinCircle(code)
.then(resp => {
clearPendingInvite()
if (resp.ok) {
showAcknowledgment(
'Your request has been sent. A circle admin will need to approve ' +
"it before you can access the circle and its chores. We'll " +
"notify you when it's approved.",
'Request sent',
() => navigate('/chores'),
'Got it',
'success',
)
} else {
setIsJoining(false)
if (resp.status === 409) {
showError('You are already a member of this circle')
} else {
showError('Failed to join circle')
}
navigate('/chores')
}
})
.catch(() => {
setIsJoining(false)
clearPendingInvite()
showError('Could not send your join request. Please try again.')
})
}, [code, navigate, showAcknowledgment, showError])
// Coming back from login/signup the user already said yes by opening the
// link, so send the request instead of asking a second time. This step used
// to be missing entirely: the login page was a dead end.
useEffect(() => {
if (autoJoinAttempted.current) return
if (!code || !isReturningFromAuth) return
if (!isAuthenticated || !userProfile) return
autoJoinAttempted.current = true
submitJoin()
}, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
// Park the code so it survives the round-trip, including OAuth flows that
// leave the app entirely.
const goToAuth = destination => {
setPendingInvite(code)
navigate(destination)
}
const inviteCodeField = (
<Input
value={code || ''}
readOnly
size='lg'
slotProps={{ input: { style: { textAlign: 'center', fontWeight: 600 } } }}
/>
)
let title = "You're invited to join a circle"
let subtitle = null
let body = null
if (!code) {
title = 'Invite link is incomplete'
subtitle =
'This invite link is missing a code. Ask the person who invited you to send a new link.'
body = (
<Button
fullWidth
size='lg'
sx={authButtonSx}
onClick={() => navigate('/chores')}
>
Go to Donetick
</Button>
)
// A token that no longer resolves to a profile is as good as signed out —
// better to offer sign-in than to spin forever.
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
subtitle =
"Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
body = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{inviteCodeField}
<Button
fullWidth
size='lg'
sx={authButtonSx}
onClick={() => goToAuth('/login')}
>
Sign in
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='neutral'
sx={authButtonSx}
onClick={() => goToAuth('/signup')}
>
Create an account
</Button>
</Box>
)
} else if (isProfileLoading || isJoining) {
title = 'Sending your request'
subtitle = 'Sending your request…'
body = (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress />
</Box>
)
} else {
subtitle =
`Hi ${userProfile?.displayName || userProfile?.username}. ` +
"Send a request to share this circle's chores with its members."
body = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography
level='body-sm'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
A circle admin will review your request before you get access.
</Typography>
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
Send join request
</Button>
<Button
fullWidth
size='lg'
variant='plain'
color='neutral'
sx={authButtonSx}
onClick={() => {
clearPendingInvite()
navigate('/chores')
}}
>
Cancel
</Button>
</Box>
)
}
return (
<Container
<Box
component='main'
maxWidth='xs'
// make content center in the middle of the page:
sx={{
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
px: 3,
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
bgcolor: 'background.body',
}}
>
<Box
sx={{
marginTop: 4,
width: '100%',
maxWidth: 420,
my: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Sheet
component='form'
<Box sx={{ mb: 2, ...enter(0) }}>
<CircleVignette />
</Box>
<Box
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
textAlign: 'center',
gap: 1.5,
mb: 4,
...enter(60),
}}
>
<Logo />
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
<Typography
level='h1'
sx={{
fontSize: '2rem',
fontWeight: 700,
letterSpacing: '-0.02em',
textWrap: 'balance',
}}
>
{title}
</Typography>
{subtitle && (
<Typography
level='body-md'
sx={{
color: 'text.secondary',
maxWidth: '34ch',
textWrap: 'pretty',
}}
>
tick
</span>
</Typography>
{code && userProfile && (
<>
<Typography level='body-md' alignSelf={'center'}>
Hi {userProfile?.displayName}, you have been invited to join the
circle{' '}
</Typography>
<Input
fullWidth
placeholder='Enter code'
value={code}
disabled={!!code}
size='lg'
sx={{
width: '220px',
mb: 1,
}}
/>
<Typography level='body-md' alignSelf={'center'}>
Joining will give you access to the circle's chores and members.
</Typography>
<Typography level='body-md' alignSelf={'center'}>
You can leave the circle later from you Settings page.
</Typography>
<Button
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
disabled={isJoining}
onClick={() => {
setIsJoining(true)
JoinCircle(code).then(resp => {
if (resp.ok) {
showAcknowledgment(
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
'Join Request Sent!',
() => navigate('/'),
'Got it',
'success',
)
} else {
setIsJoining(false)
if (resp.status === 409) {
showError('You are already a member of this circle')
} else {
showError('Failed to join circle')
}
navigate('/')
}
})
}}
>
{isJoining ? 'Joining...' : 'Join Circle'}
</Button>
<Button
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/chores')
}}
>
Cancel
</Button>
</>
{subtitle}
</Typography>
)}
{!code ||
(!userProfile && (
<>
<Typography level='body-md' alignSelf={'center'}>
You need to be logged in to join a circle
</Typography>
<Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
Login or sign up to continue
</Typography>
<Button
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
navigate('/login')
}}
>
Login
</Button>
</>
))}
</Sheet>
</Box>
<Box sx={{ ...enter(120) }}>{body}</Box>
</Box>
<AcknowledgmentModal config={ackModalConfig} />
</Container>
</Box>
)
}

View File

@@ -100,9 +100,13 @@ const IconHalo = ({ color = 'primary', icon }) => (
* user, everything else gathered automatically. The diagnostics are shown
* before sending rather than after — people are more willing to send a report
* they can see, and this is the one moment they already distrust the app.
*
* Also reached deliberately from settings with no error attached, where the
* same diagnostics back a bug the user noticed but the app never threw on.
*/
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
const { ResponsiveModal } = useResponsiveModal()
const isBugReport = !error
const [report, setReport] = useState(null)
const [description, setDescription] = useState('')
@@ -160,7 +164,10 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
{step === STEP.FORM && (
<Stack spacing={2}>
<Box sx={{ ...enter(0) }}>
<IconHalo icon={<BugReportRounded />} color='danger' />
<IconHalo
icon={<BugReportRounded />}
color={isBugReport ? 'warning' : 'danger'}
/>
</Box>
<Box sx={{ textAlign: 'center', ...enter(50) }}>
@@ -168,26 +175,33 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
>
Report this problem
{isBugReport ? 'Report a bug' : 'Report this problem'}
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
>
A sentence about what you were doing turns this into something we
can actually fix.
{isBugReport
? 'Tell us what went wrong and well attach the technical details for you.'
: 'A sentence about what you were doing turns this into something we can actually fix.'}
</Typography>
</Box>
<FormControl sx={{ ...enter(100) }}>
<FormLabel sx={{ fontWeight: 600 }}>What were you doing?</FormLabel>
<FormLabel sx={{ fontWeight: 600 }}>
{isBugReport ? 'What went wrong?' : 'What were you doing?'}
</FormLabel>
<Textarea
minRows={3}
maxRows={6}
autoFocus
value={description}
onChange={e => setDescription(e.target.value)}
placeholder='e.g. I tapped a chore in My Chores and the screen went blank'
placeholder={
isBugReport
? 'e.g. Completing a chore from the list doesnt update the due date'
: 'e.g. I tapped a chore in My Chores and the screen went blank'
}
/>
</FormControl>
@@ -279,7 +293,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
size='lg'
fullWidth
loading={submitting}
disabled={!report}
// A crash report stands on its own; a manual one is only the
// description, so there's nothing to send without it.
disabled={!report || (isBugReport && !description.trim())}
onClick={handleSubmit}
>
Send report
@@ -293,7 +309,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
underline='hover'
onClick={onClose}
>
Not now
{isBugReport ? 'Cancel' : 'Not now'}
</Link>
</Box>
</Stack>

View File

@@ -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
</Link>

View File

@@ -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 <LoadingComponent />
}
@@ -128,84 +161,85 @@ const CircleSettings = () => {
link below. You'll receive a notification below when someone requests
to join your Circle.
</Typography>
<Typography level='title-sm' mb={-1}>
<Box>
<Typography level='title-sm' sx={{ mb: 1 }}>
{userCircles[0]?.userRole === 'member'
? `You part of ${userCircles[0]?.name} `
: `You circle code is:`}
</Typography>
<Input
value={userCircles[0]?.invite_code}
value={inviteCode}
disabled
size='lg'
sx={{
width: '220px',
width: { xs: '100%', sm: '220px' },
mb: 1,
}}
/>
<Button
variant='soft'
onClick={() => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
showNotification({
type: 'success',
message: 'Code copied to clipboard',
})
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 1,
}}
>
Copy Code
</Button>
<Button
variant='soft'
sx={{ ml: 1 }}
onClick={() => {
navigator.clipboard.writeText(
window.location.protocol +
'//' +
window.location.host +
`/circle/join?code=${userCircles[0]?.invite_code}`,
)
showNotification({
type: 'success',
message: 'Link copied to clipboard',
})
}}
>
Copy Link
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
sx={{ ml: 1 }}
variant='soft'
startDecorator={<CopyAll />}
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
</Button>
)}
</Typography>
<Button
variant='soft'
disabled={!inviteLink}
startDecorator={<IosShare />}
onClick={shareInvite}
>
Share Invite
</Button>
{userCircles.length > 0 &&
userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
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',
)
}}
>
Leave Circle
</Button>
)}
</Box>
</Box>
<Typography level='title-md'>Circle Members</Typography>
{circleMembers.map(member => (
@@ -227,8 +261,7 @@ const CircleSettings = () => {
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join{' '}
{fmt.date(member.updatedAt)}
Request to join {fmt.date(member.updatedAt)}
</Typography>
)}
</Box>

View File

@@ -1,6 +1,7 @@
import {
AccountCircle,
Api,
BugReport,
ChevronRight,
Circle,
Code,
@@ -35,9 +36,11 @@ import {
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
import ErrorReportModal from '../Modals/ErrorReportModal'
import FeedbackModal from '../Modals/FeedbackModal'
const SettingsOverview = () => {
@@ -45,6 +48,7 @@ const SettingsOverview = () => {
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [bugReportOpen, setBugReportOpen] = useState(false)
const settingsCards = [
{
@@ -133,6 +137,13 @@ const SettingsOverview = () => {
icon: <Feedback />,
onSelect: () => setFeedbackOpen(true),
},
{
id: 'bugreport',
title: t('overview.sections.bugReport.title'),
description: t('overview.sections.bugReport.description'),
icon: <BugReport />,
onSelect: () => setBugReportOpen(true),
},
]
const handleCardClick = setting => {
@@ -387,6 +398,13 @@ const SettingsOverview = () => {
open={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
{/* No error to pass: the report is about something the user saw, not
something the app threw, so the modal collects diagnostics only. */}
<ErrorReportModal
open={bugReportOpen}
onClose={() => setBugReportOpen(false)}
/>
</Container>
)
}

View File

@@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const AutocompleteDropdown = ({
currentValue,
suggestions,
selectedIndex,
onSelectSuggestion,
onMouseEnterSuggestion, // Added for hover selection
onCreateSuggestion, // Called when the "Create new" row is chosen
onMouseEnterSuggestion, // Added for hover selection
onSelectSuggestion,
parentRefer, // Ref to the dropdown element
selectedIndex,
suggestions,
}) => {
// Scroll selected item into view
const dropdownMenuRef = React.useRef(null)
@@ -60,7 +62,7 @@ const AutocompleteDropdown = ({
position: 'relative',
bottom: 0,
left: 0,
zIndex: 1300,
zIndex: Z_INDEX.MODAL_POPOVER,
}}
>
{filteredOptions.map((option, index) => (

View File

@@ -1,18 +1,33 @@
import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useFileUpload } from '../../hooks/useFileUpload'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService'
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import { CreateLabel } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount } from '../../utils/Helpers'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import {
parseAssignees,
parseDueDate,
@@ -21,27 +36,14 @@ import {
parsePriority,
parseRepeatV2,
} from './CustomParsers'
import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService'
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField'
import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel'
import SmartTaskTitleInput from './SmartTaskTitleInput'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
@@ -106,7 +108,67 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
if (saved) {
try {
const project = JSON.parse(saved)
return project?.id || 'default'
} catch {
return 'default'
}
}
return 'default'
}
const PRIORITY_COLORS = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
2: TASK_COLOR.PRIORITY_2,
3: TASK_COLOR.PRIORITY_3,
4: TASK_COLOR.PRIORITY_4,
}
const PRIORITY_LABELS = {
0: '--',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
// Static option sets for the smart input's trigger suggestions
const PRIORITY_SUGGESTIONS = {
value: 'id',
display: 'name',
options: [
{ id: '1', name: 'P1' },
{ id: '2', name: 'P2' },
{ id: '3', name: 'P3' },
{ id: '4', name: 'P4' },
],
}
const POINTS_SUGGESTIONS = {
value: 'id',
display: 'name',
options: [
{ id: '1', name: '1 point' },
{ id: '5', name: '5 points' },
{ id: '10', name: '10 points' },
{ id: '25', name: '25 points' },
{ id: '50', name: '50 points' },
{ id: '100', name: '100 points' },
],
}
// Delay between the last keystroke and the smart-input parse. Parsing (chrono
// especially) is too heavy to run per keystroke; submitChore flushes a pending
// parse so a fast type-then-Enter never creates from stale parsed state.
const PARSE_DEBOUNCE_MS = 150
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
@@ -136,38 +198,85 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
[queryClient],
)
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
if (saved) {
try {
const project = JSON.parse(saved)
return project?.id || 'default'
} catch {
return 'default'
}
}
return 'default'
}
const smartInputSuggestions = useMemo(
() => ({
'#': {
value: 'id',
display: 'name',
options: userLabels || [],
creatable: true,
onCreate: handleCreateLabel,
},
'!': PRIORITY_SUGGESTIONS,
'@': {
value: 'userId',
display: 'displayName',
options: [
{ userId: 'anyone', displayName: 'Anyone' },
...(circleMembers?.res || []),
],
},
'*': POINTS_SUGGESTIONS,
}),
[userLabels, circleMembers, handleCreateLabel],
)
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
// Highlight spans paired with the text they were computed from: the parse
// is debounced, so while typing these lag behind taskText
const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] })
// What the smart input overlay shows. While a parse is pending, keep every
// highlight span that precedes the edit point and render the rest as plain
// text — existing token styles must not flicker away on each keystroke.
const displayedParts = useMemo(() => {
const { parts, text } = renderedParts
if (text === taskText) return parts
let prefixLen = 0
const max = Math.min(text.length, taskText.length)
while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) {
prefixLen++
}
const kept = []
let consumed = 0
for (const part of parts) {
const partText = typeof part === 'string' ? part : part.props.children
if (consumed + partText.length > prefixLen) break
kept.push(part)
consumed += partText.length
}
kept.push(taskText.slice(consumed))
return kept
}, [renderedParts, taskText])
const richTextEditorRef = useRef(null)
const latestRef = useRef({})
// Picker edits made on a voice task card, applied once after the reparse
// that follows landing the spoken text in the smart input
const pendingVoiceOverridesRef = useRef(null)
// True while the current assignees came from an @mention in the text, so a
// reparse without mentions only resets what a mention set — never a
// selection made directly in the assignee picker
const assigneesFromMentionRef = useRef(false)
// Pending debounced parse of the smart input text, if any
const parseTimerRef = useRef(null)
// Identities (type + text) of the highlights from the previous parse, so
// the appear animation only plays for tokens detected just now
const prevHighlightKeysRef = useRef(new Set())
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [assignees, setAssignees] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [frequency, setFrequency] = useState(null)
const [notificationMetadata, setNotificationMetadata] = useState({
// Lazy initializers: these read localStorage, which must not happen on
// every render
const [notificationMetadata, setNotificationMetadata] = useState(() => ({
templates: getDefaultNotification(),
})
}))
const [subTasks, setSubTasks] = useState(null)
const [points, setPoints] = useState(-1)
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
@@ -183,13 +292,14 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [projectId, setProjectId] = useState(getInitialProject)
const [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => generateUUID())
const [showScan, setShowScan] = useState(false)
const [scanAutoCapture, setScanAutoCapture] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
const [isAttachingScan, setIsAttachingScan] = useState(false)
const [llmAvailable, setLlmAvailable] = useState(false)
const [showVoice, setShowVoice] = useState(false)
const [voiceAvailable, setVoiceAvailable] = useState(false)
@@ -213,6 +323,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
primaryAction: null,
})
const { isNativeScanner } = useDocumentScanner()
const { uploadFile } = useFileUpload({
entityType: 'chore_attachment_draft',
draftId,
})
useEffect(() => {
localAIService.isAvailable().then(setLlmAvailable)
@@ -244,23 +358,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
// Priority colors
const priorityColors = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
2: TASK_COLOR.PRIORITY_2,
3: TASK_COLOR.PRIORITY_3,
4: TASK_COLOR.PRIORITY_4,
}
const priorityLabels = {
0: '--',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
if (hasDescription && richTextEditorRef.current) {
@@ -274,11 +371,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
isModalOpen,
hasDescription,
dueDate,
createChore,
handleCloseModal,
hasDescription,
isModalOpen,
submitChore,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -316,7 +413,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
isModalOpen
) {
event.preventDefault()
createChore()
submitChore()
return
}
if (event.key === 'Escape' && isModalOpen) {
@@ -404,6 +501,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
}
const seenHighlightKeys = new Set()
for (const highlight of resolvedHighlights) {
if (highlight.start > lastIndex) {
const textBefore = sentence.substring(lastIndex, highlight.start)
@@ -439,10 +537,13 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
highlight.start,
highlight.end,
)
const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}`
const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey)
seenHighlightKeys.add(highlightKey)
parts.push(
<span
key={highlight.start}
className={className}
className={`${className}${isNewHighlight ? ' highlight-appear' : ''}`}
style={{
textDecoration: 'underline',
textDecorationThickness: '2px',
@@ -455,6 +556,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
lastIndex = highlight.end
}
prevHighlightKeysRef.current = seenHighlightKeys
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
@@ -470,14 +572,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
[],
)
const processText = useCallback(
sentence => {
const priority = parsePriority(sentence)
const pointsParsed = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels || [])
const circleMembersList = circleMembers?.res || []
const assigneesForParsing = circleMembersList.map(member => ({
// Rebuilt only when the member list actually changes, so a query refetch
// with identical data doesn't re-trigger the parse effect below
const assigneesForParsing = useMemo(
() =>
(circleMembers?.res || []).map(member => ({
userId: member.userId,
username:
member.username ||
@@ -485,7 +584,15 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
displayName: member.displayName,
name: member.displayName,
id: member.userId,
}))
})),
[circleMembers],
)
const processText = useCallback(
sentence => {
const priority = parsePriority(sentence)
const pointsParsed = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels || [])
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
const repeat = parseRepeatV2(sentence)
@@ -503,14 +610,18 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
// @Anyone was used - set empty assignees (anyone can do the task)
setIsAnyoneTask(true)
setAssignees([])
assigneesFromMentionRef.current = true
} else if (assigneesResult.result && assigneesResult.result.length > 0) {
setIsAnyoneTask(false)
const parsedAssignees = assigneesResult.result.map(assignee => ({
userId: assignee.userId,
}))
setAssignees(parsedAssignees)
} else {
// Only assign to current user if no @ mentions found and userProfile exists
assigneesFromMentionRef.current = true
} else if (assigneesFromMentionRef.current) {
// The @mention that set the current assignees was deleted — fall back
// to the implicit self default. Picker selections stay untouched.
assigneesFromMentionRef.current = false
setIsAnyoneTask(false)
if (userProfile?.id) {
setAssignees([
@@ -548,39 +659,47 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
syncDueDateStates(repeat.dueDate)
}
// Create the cleaned sentence by sequentially applying all cleanups
// Create the cleaned sentence by sequentially applying all cleanups.
// Each stage only needs a reparse when an earlier cleanup actually
// changed the sentence; otherwise the first-pass result (computed on the
// identical string) is reused as-is.
let cleanedSentence = sentence
if (priority.result) cleanedSentence = priority.cleanedSentence
if (pointsParsed.result) {
// Apply points cleaning to the current cleaned sentence
const pointsReparse = parsePoints(cleanedSentence)
const pointsReparse =
cleanedSentence === sentence
? pointsParsed
: parsePoints(cleanedSentence)
if (pointsReparse.result)
cleanedSentence = pointsReparse.cleanedSentence
}
if (labels.result) {
// Apply labels cleaning to the current cleaned sentence
const labelsReparse = parseLabels(cleanedSentence, userLabels || [])
const labelsReparse =
cleanedSentence === sentence
? labels
: parseLabels(cleanedSentence, userLabels || [])
if (labelsReparse.result)
cleanedSentence = labelsReparse.cleanedSentence
}
if (assigneesResult.result) {
// Apply assignees cleaning to the current cleaned sentence
const assigneesReparse = parseAssignees(
cleanedSentence,
assigneesForParsing,
)
const assigneesReparse =
cleanedSentence === sentence
? assigneesResult
: parseAssignees(cleanedSentence, assigneesForParsing)
if (assigneesReparse.result)
cleanedSentence = assigneesReparse.cleanedSentence
}
if (repeat.result) {
// Apply repeat cleaning to the current cleaned sentence
const repeatReparse = parseRepeatV2(cleanedSentence)
const repeatReparse =
cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence)
if (repeatReparse.result)
cleanedSentence = repeatReparse.cleanedSentence
}
if (dueDateParsed.result) {
// Apply date cleaning to the current cleaned sentence
const dueDateReparse = parseDueDate(cleanedSentence, chrono)
const dueDateReparse =
cleanedSentence === sentence
? dueDateParsed
: parseDueDate(cleanedSentence, chrono)
if (dueDateReparse.result)
cleanedSentence = dueDateReparse.cleanedSentence
}
@@ -599,7 +718,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
assigneesResult.highlight,
)
setRenderedParts(parts)
setRenderedParts({ text: sentence, parts })
const overrides = pendingVoiceOverridesRef.current
if (overrides) {
@@ -615,6 +734,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
assigneesFromMentionRef.current = false
}
if ('dueDate' in overrides) {
if (overrides.dueDate) {
@@ -628,7 +748,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
}
},
[userLabels, renderHighlightedSentence, circleMembers, userProfile],
[userLabels, renderHighlightedSentence, assigneesForParsing, userProfile],
)
useEffect(() => {
@@ -641,7 +761,16 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
return
}
processText(taskText)
// Debounced so fast typing doesn't run the full parse pipeline per
// keystroke; submitChore flushes a pending parse before creating.
parseTimerRef.current = setTimeout(() => {
parseTimerRef.current = null
processText(taskText)
}, PARSE_DEBOUNCE_MS)
return () => {
clearTimeout(parseTimerRef.current)
parseTimerRef.current = null
}
}, [
taskText,
userLabelsLoading,
@@ -706,14 +835,41 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
const handleEnterPressed = () => {
createChore()
submitChore()
}
// The scan keeps its source image when asked: upload it against the draft so
// the server promotes it onto the chore the same way manual uploads are.
const attachScannedImage = async imageSource => {
// Creating the chore promotes whatever draft attachments exist at that
// moment, so Create waits on this upload rather than orphaning it.
setIsAttachingScan(true)
try {
const file = await imageSourceToFile(
imageSource,
`scan-${Date.now()}.jpg`,
)
if (!file) return
const uploaded = await uploadFile(file)
if (!uploaded) return
setAttachments(prev => [
...prev,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
} finally {
setIsAttachingScan(false)
}
}
const handleTaskExtracted = ({
taskName,
attachmentImage,
description: extractedDesc,
dueDate: extractedDue,
taskName,
}) => {
if (attachmentImage) {
attachScannedImage(attachmentImage)
}
if (taskName) {
processText(taskName)
}
@@ -801,6 +957,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
setIsAttachingScan(false)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -814,6 +971,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setHasSubTasks(false)
setLabelsV2([])
setAssignees([])
assigneesFromMentionRef.current = false
// The modal closes without a final parse, so drop the highlight identities
// here or nothing would animate on the next open
prevHighlightKeysRef.current = new Set()
setProjectId(getInitialProject())
setDeadlineOffset(-1)
setRequireApproval(false)
@@ -829,6 +990,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
const createChore = () => {
// A scanned attachment still uploading would be orphaned by the create
if (isAttachingScan) return
// Handle different assignee scenarios
let finalAssignees = assignees
let finalAssignedTo = null
@@ -916,11 +1080,26 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
handleCloseModal(false)
}
// All submit paths (Enter, Cmd+Enter, footer button) go through here: a
// debounce may still be holding the parse of the latest text, and creating
// from pre-parse state would drop the tail of what the user typed.
const submitChore = () => {
if (parseTimerRef.current) {
clearTimeout(parseTimerRef.current)
parseTimerRef.current = null
flushSync(() => processText(taskText))
}
// Read through latestRef: after the flush, this render's createChore
// closure is stale
latestRef.current.createChore()
}
latestRef.current = {
isModalOpen,
hasDescription,
dueDate,
createChore,
submitChore,
handleCloseModal,
}
@@ -988,8 +1167,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<Button
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
loading={isAttachingScan}
disabled={!taskTitle.trim() || isAttachingScan}
onClick={submitChore}
>
Create
{showKeyboardShortcuts && (
@@ -1002,8 +1182,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
>
{!showScan && !showVoice && (
<>
<Box>
<Box
<Box sx={{ mt: 1 }}>
{/* <Box
sx={{
display: 'flex',
flexDirection: 'row',
@@ -1054,7 +1234,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
</>
}
/>
</Box>
</Box> */}
<SmartTaskTitleInput
autoFocus
@@ -1085,7 +1265,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setTaskText(text)
if (!text) setTaskTitle('')
}}
customRenderer={renderedParts}
customRenderer={displayedParts}
onEnterPressed={handleEnterPressed}
onShiftEnterPressed={() => {
if (!hasDescription) {
@@ -1093,45 +1273,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
setTimeout(() => richTextEditorRef.current?.focus(), 50)
}}
suggestions={{
'#': {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
creatable: true,
onCreate: handleCreateLabel,
},
'!': {
value: 'id',
display: 'name',
options: [
{ id: '1', name: 'P1' },
{ id: '2', name: 'P2' },
{ id: '3', name: 'P3' },
{ id: '4', name: 'P4' },
],
},
'@': {
value: 'userId',
display: 'displayName',
options: [
{ userId: 'anyone', displayName: 'Anyone' },
...(circleMembers?.res || []),
],
},
'*': {
value: 'id',
display: 'name',
options: [
{ id: '1', name: '1 point' },
{ id: '5', name: '5 points' },
{ id: '10', name: '10 points' },
{ id: '25', name: '25 points' },
{ id: '50', name: '50 points' },
{ id: '100', name: '100 points' },
],
},
}}
suggestions={smartInputSuggestions}
/>
</Box>
@@ -1173,8 +1315,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
priorityColors={PRIORITY_COLORS}
priorityLabels={PRIORITY_LABELS}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
@@ -1253,9 +1395,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Description
</Typography>
<Typography level='body-sm'>Description</Typography>
</Button>
)}
{!hasSubTasks && (
@@ -1278,9 +1418,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Subtasks
</Typography>
<Typography level='body-sm'>Subtasks</Typography>
</Button>
)}
<AdvancedOptionsTrigger
@@ -1312,7 +1450,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
onAssignStrategyChange={setAssignStrategy}
hasDueDate={!!dueDate}
hasMultipleAssignees={assignees.length > 1}
hasAssignees={assignees.length > 0}
// Empty assignees still implicitly assigns the current user at
// create time; only an "Anyone" task truly has no assignee
hasAssignees={!isAnyoneTask}
isPrivate={isPrivate}
onIsPrivateChange={setIsPrivate}
/>
@@ -1359,6 +1499,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<ScanPanel
open
autoCapture={scanAutoCapture}
canKeepImage={isPlusAccount(userProfile)}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onStateChange={setScanState}

View File

@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
<Typography
level='body-sm'
sx={{
color: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -1,30 +1,55 @@
import { Person } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const ANYONE = 'anyone'
const AssigneePickerField = ({
value = null,
currentUserId = null,
emptyDisplay,
includeAnyone = true,
isAnyone = false,
members = [],
onChange,
onClear,
members = [],
includeAnyone = true,
emptyDisplay,
currentUserId = null,
values = [],
}) => {
const options = [
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
...(includeAnyone ? [{ userId: ANYONE, displayName: 'Anyone' }] : []),
...members.map(member => ({
userId: member.userId,
displayName: member.displayName || member.username || 'Unknown',
})),
]
const displayValue = currentUserId && value === currentUserId ? null : value
// An implicit self-assignment is shown as "unset" so the chip stays empty
// until the user picks someone explicitly.
const isImplicitSelf =
!isAnyone &&
currentUserId &&
values.length === 1 &&
values[0] === currentUserId
const displayValues = isAnyone ? [ANYONE] : isImplicitSelf ? [] : values
const handleValuesChange = nextValues => {
const wasAnyone = displayValues.includes(ANYONE)
const hasAnyone = nextValues.includes(ANYONE)
if (hasAnyone && !wasAnyone) {
onChange?.([ANYONE])
return
}
onChange?.(nextValues.filter(userId => userId !== ANYONE))
}
return (
<BaseOptionPicker
items={options}
value={displayValue}
onChange={onChange}
multiple
values={displayValues}
onValuesChange={handleValuesChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
@@ -32,9 +57,11 @@ const AssigneePickerField = ({
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
getTriggerText={({ isEmpty, selectedItems }) => {
if (isEmpty) return 'Assignee'
if (selectedItems.length === 1) return selectedItems[0].displayName
return `${selectedItems.length} assignees`
}}
menuMinWidth={220}
/>
)

View File

@@ -1,4 +1,12 @@
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
import {
AttachFile,
Close,
DeleteOutline,
DocumentScanner,
Image,
InsertDriveFile,
PhotoCamera,
} from '@mui/icons-material'
import {
Box,
Button,
@@ -9,23 +17,41 @@ import {
} from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useFileUpload } from '../../hooks/useFileUpload'
import { useNotification } from '../../service/NotificationProvider'
import { DeleteDraftAttachment } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const isImageAttachment = attachment => {
const ext = (attachment?.name || '').split('.').pop()?.toLowerCase()
return IMAGE_EXTENSIONS.includes(ext)
}
const AttachmentPickerField = ({
attachments = [],
draftId,
emptyDisplay = 'icon-text',
entityId,
entityType = 'chore_attachment',
onChange,
onClear,
emptyDisplay = 'icon-text',
entityType = 'chore_attachment',
entityId,
draftId,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
const { isNativeScanner, scanDocument } = useDocumentScanner()
const { showError } = useNotification()
// Without a native scanner, `capture` asks a phone for its camera directly.
// Desktop browsers ignore it and fall back to the file picker, which would
// duplicate "Image", so the button only appears on touch devices.
const canTakePhoto = isNativeScanner || navigator.maxTouchPoints > 0
useEffect(() => {
if (!isOpen) return
@@ -36,27 +62,60 @@ const AttachmentPickerField = ({
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
const handleAddFile = () => {
const upload = async file => {
setIsUploading(true)
try {
const uploaded = await uploadFile(file)
if (uploaded) {
onChange([
...attachments,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
}
} finally {
setIsUploading(false)
}
}
const handlePickFile = ({ accept, capture } = {}) => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
if (accept) input.setAttribute('accept', accept)
if (capture) input.setAttribute('capture', capture)
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
setIsUploading(true)
try {
const uploaded = await uploadFile(file)
if (uploaded) {
onChange([
...attachments,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
}
} finally {
setIsUploading(false)
}
if (file) upload(file)
}
input.click()
}
// Native builds get the OS document scanner (edge detection + perspective
// correction); everywhere else "take photo" is the camera roll shortcut.
const handleScan = async () => {
if (!isNativeScanner) {
handlePickFile({ accept: 'image/*', capture: 'environment' })
return
}
const { cancelled, error, image } = await scanDocument()
if (cancelled) return
if (error || !image) {
showError({
title: 'Scan Failed',
message: error || 'Could not scan the document.',
})
return
}
setIsUploading(true)
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
setIsUploading(false)
if (!file) {
showError({
title: 'Scan Failed',
message: 'Could not read the scanned image.',
})
return
}
await upload(file)
}
const handleRemove = async index => {
@@ -199,26 +258,30 @@ const AttachmentPickerField = ({
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
{isImageAttachment(attachment) && (
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
)}
<Box
sx={{
display: 'none',
display: isImageAttachment(attachment)
? 'none'
: 'flex',
width: 36,
height: 36,
alignItems: 'center',
@@ -228,7 +291,15 @@ const AttachmentPickerField = ({
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
{isImageAttachment(attachment) ? (
<Image
sx={{ fontSize: 20, color: 'text.tertiary' }}
/>
) : (
<InsertDriveFile
sx={{ fontSize: 20, color: 'text.tertiary' }}
/>
)}
</Box>
<Typography
level='body-xs'
@@ -255,26 +326,64 @@ const AttachmentPickerField = ({
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
{isUploading ? (
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
disabled
startDecorator={
<CircularProgress
size='sm'
sx={{ '--CircularProgress-size': '14px' }}
/>
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
}
>
Uploading
</Button>
) : (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{canTakePhoto && (
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={
isNativeScanner ? (
<DocumentScanner sx={{ fontSize: 16 }} />
) : (
<PhotoCamera sx={{ fontSize: 16 }} />
)
}
onClick={handleScan}
>
{isNativeScanner ? 'Scan' : 'Photo'}
</Button>
)}
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={<Image sx={{ fontSize: 16 }} />}
onClick={() => handlePickFile({ accept: 'image/*' })}
>
Image
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={<AttachFile sx={{ fontSize: 16 }} />}
onClick={() => handlePickFile()}
>
File
</Button>
</Box>
)}
</Sheet>
</ClickAwayListener>
</Popper>

View File

@@ -9,6 +9,7 @@ import {
ListAlt,
Logout,
MenuRounded,
SearchRounded,
SettingsOutlined,
Toll,
Widgets,
@@ -23,30 +24,36 @@ import {
ListItemDecorator,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import Z_INDEX from '../../constants/zIndex'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResource } from '../../queries/ResourceQueries'
import { useGlobalSearch } from '../../search/GlobalSearchContext'
import { apiClient } from '../../utils/ApiClient'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
import Z_INDEX from '../../constants/zIndex'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient'
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { t } = useTranslation('common')
const { isRTL } = useLocalization()
const { data: resource } = useResource()
const { openSearch } = useGlobalSearch()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
{
label: t('navigation.search'),
icon: <SearchRounded />,
onClick: () => openSearch(),
},
{
to: '/chores',
label: t('navigation.allTasks'),
@@ -105,6 +112,22 @@ const NavBar = () => {
<MenuRounded />
</IconButton>
)
if (location.pathname === '/search') {
return (
<IconButton
size='md'
variant='plain'
onClick={() => {
if (window.history.state?.idx > 0) navigate(-1)
else navigate('/chores', { replace: true })
}}
aria-label='Back from search'
title={t('back')}
>
<ArrowBack />
</IconButton>
)
}
if (!Capacitor.isNativePlatform()) {
return menuRounded
}
@@ -150,6 +173,9 @@ const NavBar = () => {
'/onboarding',
'/get-started',
'/ready',
// Reached from an invite link, often signed out: it owns its own shell
// and must not mount the avatar's authenticated queries.
'/circle/join',
].includes(location.pathname)
) {
return (

View File

@@ -7,13 +7,12 @@ import {
import { Link } from 'react-router-dom'
const NavBarLink = ({ link }) => {
const { to, icon, label } = link
const { to, icon, label, onClick } = link
return (
<ListItem>
<ListItemButton
key={to}
component={Link}
to={to}
{...(onClick ? { onClick } : { component: Link, to })}
variant='plain'
color='neutral'
sx={{

View File

@@ -8,11 +8,13 @@ import {
import {
Box,
Button,
Checkbox,
CircularProgress,
LinearProgress,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useScanToTask } from './useScanToTask'
/**
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
* belongs to the capture surface and drives a hidden input in this subtree.
*/
const ScanPanel = ({
open,
onTaskExtracted,
autoCapture,
canKeepImage = false,
initialImageUrl,
onClose,
onStateChange,
initialImageUrl,
autoCapture,
onTaskExtracted,
open,
}) => {
const {
isNativeScanner,
phase,
capturedImage,
ocrProgress,
taskResult,
errorMsg,
activate,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
capturedImage,
errorMsg,
fileInputRef,
handleFileSelect,
handleNativeScan,
retake,
activate,
isNativeScanner,
ocrProgress,
phase,
reset,
retake,
startCamera,
stopCamera,
taskResult,
videoRef,
} = useScanToTask()
// The scanned page is usually the task's source of truth (the bill, the
// notice), so keeping it is the default — the OCR text alone loses it.
const [keepImage, setKeepImage] = useState(false)
// Start/stop based on open state
useEffect(() => {
if (open) {
@@ -82,7 +89,10 @@ const ScanPanel = ({
// Auto-close and populate when done
useEffect(() => {
if (phase === 'done' && taskResult) {
onTaskExtracted(taskResult)
onTaskExtracted({
...taskResult,
attachmentImage: canKeepImage && keepImage ? capturedImage : null,
})
onClose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -137,6 +147,18 @@ const ScanPanel = ({
const isProcessing = phase === 'processing'
// Attachments are a Plus feature; without it the upload would only ever
// surface an upgrade error, so the choice isn't offered at all.
const keepImageToggle = !canKeepImage ? null : (
<Checkbox
size='sm'
checked={keepImage}
onChange={e => setKeepImage(e.target.checked)}
label='Keep photo as attachment'
sx={{ '--Checkbox-size': '18px' }}
/>
)
return (
<Box>
{/* ── Capture phase ── */}
@@ -201,16 +223,18 @@ const ScanPanel = ({
)}
</Box>
{/* Hidden when Upload is already the footer's primary action */}
{(isNativeScanner || cameraAvailable) && (
<Box
sx={{
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box
sx={{
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1,
}}
>
{/* Hidden when Upload is already the footer's primary action */}
{(isNativeScanner || cameraAvailable) && (
<Button
size='sm'
variant='plain'
@@ -220,8 +244,9 @@ const ScanPanel = ({
>
Upload
</Button>
</Box>
)}
)}
{keepImageToggle}
</Box>
</>
)}
@@ -285,6 +310,9 @@ const ScanPanel = ({
sx={{ width: '100%' }}
/>
)}
{/* Still editable here — the choice is only read once the task lands */}
{keepImageToggle}
</Box>
)}

View File

@@ -57,6 +57,34 @@
color: var(--highlight-label-color);
}
/* Played once when a token is first detected: the dashed underline fades in
while a soft tint of the token's own color flashes and clears. Keyframe
values override the span's inline text-decoration while running. */
@keyframes smart-highlight-in {
from {
text-decoration-color: transparent;
background-color: color-mix(in srgb, currentColor 22%, transparent);
}
60% {
background-color: color-mix(in srgb, currentColor 12%, transparent);
}
to {
text-decoration-color: currentColor;
background-color: transparent;
}
}
.highlight-appear {
border-radius: 4px;
animation: smart-highlight-in 450ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.highlight-appear {
animation: none;
}
}
.task-input {
position: relative;
width: 100%;

View File

@@ -89,17 +89,14 @@ const SmartTaskTitleInput = ({
}
}, [])
const handleSuggestionChange = text => {
// if the last word start with '@' or '#' or 'P':
const lastWord = text.split(' ').pop()
if (
lastWord.startsWith('@') ||
lastWord.startsWith('#') ||
lastWord.startsWith('!')
) {
// show the menu when the last word starts with a configured trigger
// character (e.g. '@', '#', '!', '*')
const lastWord = text.split(/\s+/).pop()
if (lastWord && suggestions?.[lastWord[0]]) {
setSuggestionTrigger(lastWord[0])
// last word without the first character:
setLastWord(lastWord.slice(1))
setSelectedSuggestionIndex(0)
setShowSuggestions(true)
} else {
setShowSuggestions(false)
@@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({
const newCursorPosition =
cursorPosition - lastWord.length + suggestionValue.length + 1
setCursorPosition(newCursorPosition)
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
@@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({
const suggestionValue = suggestions[suggestionTrigger].display
? suggestion[suggestions[suggestionTrigger].display]
: suggestion
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
onChange(newValue)
// Same insertion path as keyboard selection: replace the partial
// word typed after the trigger instead of inserting alongside it
titleInputRef?.current?.focus()
setCursorPosition(cursorPosition + suggestion.length)
titleInputRef.current.value = newValue
titleInputRef.current.setSelectionRange(
cursorPosition + suggestionValue.length,
cursorPosition + suggestionValue.length,
)
setShowSuggestions(false)
selectSuggestionText(suggestionValue)
}}
onCreateSuggestion={name => {
selectSuggestionText(name)