Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c474041c7c | ||
|
|
c649e051d8 | ||
|
|
207888affe | ||
|
|
5f0c5cdc5d | ||
|
|
5b7594ac64 | ||
|
|
90a38bf091 | ||
|
|
eecfc98ebc | ||
|
|
6428239be2 | ||
|
|
4c6e244069 | ||
|
|
af926b033e | ||
|
|
b7bdfa7885 | ||
|
|
309fd32d50 | ||
|
|
2442b0c441 | ||
|
|
5d8095bd9c | ||
|
|
1a8aeb84cc | ||
|
|
b2529a0091 | ||
|
|
841f83d6ae | ||
|
|
731c46b26a | ||
|
|
612924e7c3 | ||
|
|
78760ceebb | ||
|
|
e05aa2d169 | ||
|
|
1ddf2539e0 | ||
|
|
83a5760959 |
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
14
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
public/.well-known/apple-app-site-association
Normal file
11
public/.well-known/apple-app-site-association
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
public/.well-known/apple-app-site-association.json
Normal file
11
public/.well-known/apple-app-site-association.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
public/.well-known/assetlinks.json
Normal file
13
public/.well-known/assetlinks.json
Normal 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
8
public/_headers
Normal 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
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
||||
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Search",
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/App.jsx
11
src/App.jsx
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
192
src/search/GlobalSearchContext.jsx
Normal file
192
src/search/GlobalSearchContext.jsx
Normal 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
|
||||
}
|
||||
32
src/search/GlobalSearchPage.jsx
Normal file
32
src/search/GlobalSearchPage.jsx
Normal 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
|
||||
468
src/search/GlobalSearchPalette.jsx
Normal file
468
src/search/GlobalSearchPalette.jsx
Normal 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
|
||||
182
src/search/searchProviders.js
Normal file
182
src/search/searchProviders.js
Normal 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}`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
17
src/utils/FileConvert.js
Normal 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
|
||||
}
|
||||
}
|
||||
46
src/utils/PendingInvite.js
Normal file
46
src/utils/PendingInvite.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 we’ll 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 />}
|
||||
|
||||
@@ -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 we’ll send your circle join request right after.'
|
||||
: 'Track chores and tasks together, in one shared place.'
|
||||
}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 we’ll 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 doesn’t 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user