Merge pull request #199 from donetick/0809-fixes

0809 fixes
This commit is contained in:
Mohamad Tarbin
2026-08-09 13:17:08 -04:00
committed by GitHub
24 changed files with 904 additions and 381 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
// Routes a first-run user may legitimately be on without having gone through
@@ -11,6 +12,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 =>

View File

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

View File

@@ -96,6 +96,9 @@ export function useSyncOnReconnect() {
// the same tick as the deep link, before the route changes, so this has
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
// No session, nothing to sync — and a 401 here would force a logout that
// hard-navigates signed-out visitors (invite links) away to /login.
if (!localStorage.getItem('token')) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,40 @@
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
localStorage.setItem(INVITE_KEY, code)
// 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
}
const redirect = Cookies.get(REDIRECT_COOKIE)
if (redirect && redirect.startsWith('/circle/join')) {
Cookies.remove(REDIRECT_COOKIE)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,32 @@
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 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,19 +35,6 @@ 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'
@@ -42,6 +43,7 @@ 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,7 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
@@ -190,6 +192,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
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 +216,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)
@@ -274,11 +281,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
isModalOpen,
hasDescription,
dueDate,
createChore,
dueDate,
handleCloseModal,
hasDescription,
isModalOpen,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -709,11 +716,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
createChore()
}
// 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 +835,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
setIsAttachingScan(false)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -829,6 +864,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
@@ -988,7 +1026,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<Button
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
loading={isAttachingScan}
disabled={!taskTitle.trim() || isAttachingScan}
onClick={createChore}
>
Create
@@ -1359,6 +1398,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<ScanPanel
open
autoCapture={scanAutoCapture}
canKeepImage={isPlusAccount(userProfile)}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onStateChange={setScanState}

View File

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

View File

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

View File

@@ -150,6 +150,9 @@ const NavBar = () => {
'/onboarding',
'/get-started',
'/ready',
// Reached from an invite link, often signed out: it owns its own shell
// and must not mount the avatar's authenticated queries.
'/circle/join',
].includes(location.pathname)
) {
return (

View File

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