diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js
index 1aba6f9..e44329e 100644
--- a/src/hooks/useFileUpload.js
+++ b/src/hooks/useFileUpload.js
@@ -5,7 +5,7 @@ import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
-export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => {
+export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
@@ -38,7 +38,8 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityType', entityType)
- if (entityId) formData.append('entityId', entityId)
+ if (entityId) formData.append('entityId', String(entityId))
+ if (draftId) formData.append('draftId', draftId)
const response = await apiClient.upload('/assets/chore', formData)
@@ -84,7 +85,7 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
return null
}
},
- [entityType, entityId, showError, userProfile],
+ [entityType, entityId, draftId, showError, userProfile],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }
diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx
index 167394a..0e3caae 100644
--- a/src/utils/Fetcher.jsx
+++ b/src/utils/Fetcher.jsx
@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
})
}
+const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('entityType', entityType)
+ if (entityId != null) formData.append('entityId', String(entityId))
+ if (draftId != null) formData.append('draftId', draftId)
+ return apiClient.upload('/assets/chore', formData)
+}
+
+const GetChoreAttachments = choreId => {
+ return Fetch(`/chores/${choreId}/attachments`, {
+ method: 'GET',
+ headers: HEADERS(),
+ })
+}
+
+const DeleteChoreAttachment = (choreId, filePath) => {
+ return Fetch(`/chores/${choreId}/attachments`, {
+ method: 'DELETE',
+ headers: HEADERS(),
+ body: JSON.stringify({ file_path: filePath }),
+ })
+}
+
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
export {
AcceptCircleMemberRequest,
+ DeleteChoreAttachment,
+ GetChoreAttachments,
+ UploadChoreAttachment,
ApproveChore,
ArchiveChore,
CancelSubscription,
diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx
index fb1ef4c..18a198f 100644
--- a/src/utils/Helpers.jsx
+++ b/src/utils/Helpers.jsx
@@ -10,9 +10,108 @@ const resolvePhotoURL = url => {
if (url.startsWith('http') || url.startsWith('https')) {
return url
}
- if (url.startsWith('assets')) {
- return apiClient.getAssetURL(url)
- }
- return url
+ return apiClient.getAssetURL(url)
}
-export { isPlusAccount, resolvePhotoURL }
+
+// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
+const isCloudSignedUrl = url => {
+ if (!url) return false
+ try {
+ const u = new URL(url)
+ return (
+ u.searchParams.has('X-Amz-Signature') ||
+ u.searchParams.has('X-Amz-Expires') ||
+ u.searchParams.has('X-Goog-Signature') ||
+ u.searchParams.has('sig') // Azure Blob SAS
+ )
+ } catch {
+ return false
+ }
+}
+
+// Extract the storage key from a cloud signed URL so we can route it through
+// the backend proxy (which re-signs on every request and never expires).
+//
+// Handles:
+// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
+// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
+// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
+// GCS: https://storage.googleapis.com/{bucket}/{key}?...
+// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
+//
+// The app stores files under an "assets/" prefix in the bucket but the backend
+// proxy already mounts at /assets/, so we strip that leading segment when present.
+const extractStorageKey = url => {
+ try {
+ const u = new URL(url)
+ const host = u.hostname
+ const rawPath = u.pathname.replace(/^\//, '')
+
+ let key
+
+ if (host.endsWith('.r2.cloudflarestorage.com')) {
+ // Virtual-hosted R2: bucket is in the host, key is the full path
+ key = rawPath
+ } else if (host.endsWith('.amazonaws.com')) {
+ if (host.startsWith('s3') || host.includes('.s3.')) {
+ // Path-style S3: first segment is the bucket — strip it
+ key = rawPath.split('/').slice(1).join('/')
+ } else {
+ // Virtual-hosted S3: bucket is in the host, path is the key
+ key = rawPath
+ }
+ } else if (host === 'storage.googleapis.com') {
+ // First segment is the bucket
+ key = rawPath.split('/').slice(1).join('/')
+ } else if (host.endsWith('.blob.core.windows.net')) {
+ // First segment is the container name
+ key = rawPath.split('/').slice(1).join('/')
+ } else {
+ key = rawPath
+ }
+
+ // The bucket stores files under an "assets/" prefix; the backend /assets/
+ // endpoint already adds that prefix, so strip it to avoid duplication.
+ if (key.startsWith('assets/')) {
+ key = key.slice('assets/'.length)
+ }
+
+ return key || null
+ } catch {
+ return null
+ }
+}
+
+// Scan an HTML string for
tags whose src is a cloud signed URL and
+// replace them with backend proxy URLs (which generate fresh signed URLs on
+// each request). Returns the patched HTML, or the original if nothing changed.
+const refreshSignedUrlsInHtml = html => {
+ if (!html) return html
+ if (
+ !html.includes('X-Amz-') &&
+ !html.includes('X-Goog-') &&
+ !html.includes('.blob.core.windows.net')
+ ) {
+ return html
+ }
+
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(html, 'text/html')
+ const imgs = doc.querySelectorAll('img[src]')
+ let changed = false
+
+ imgs.forEach(img => {
+ const src = img.getAttribute('src')
+ if (!isCloudSignedUrl(src)) return
+
+ const key = extractStorageKey(src)
+ if (!key) return
+
+ img.setAttribute('src', apiClient.getAssetURL(key))
+ changed = true
+ })
+
+ return changed ? doc.body.innerHTML : html
+}
+
+export { extractStorageKey, isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index 4d5c78e..e6fdeaa 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -1,4 +1,12 @@
-import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
+import {
+ Add,
+ ArrowDropDown,
+ AttachFile,
+ Delete,
+ HorizontalRule,
+ Save,
+ UploadFile,
+} from '@mui/icons-material'
import {
Avatar,
Box,
@@ -43,7 +51,13 @@ import {
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
-import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
+import {
+ DeleteChoreAttachment,
+ GetAllCircleMembers,
+ GetChoreAttachments,
+ GetThings,
+ UploadChoreAttachment,
+} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
@@ -117,6 +131,9 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
+ const [draftId] = useState(() => crypto.randomUUID())
+ const [attachments, setAttachments] = useState([])
+ const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
const [privacySaved, setPrivacySaved] = useState(false)
@@ -362,6 +379,7 @@ const ChoreEdit = () => {
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
priority: priority,
projectId: projectId === 'default' ? null : projectId,
+ draftId: newChoreId > 0 ? undefined : draftId,
}
let SaveFunction = createChoreMutation.mutateAsync
if (newChoreId > 0) {
@@ -405,6 +423,14 @@ const ChoreEdit = () => {
setAllUserThings(data.res)
})
})
+ if (choreId) {
+ GetChoreAttachments(choreId)
+ .then(r => r.json())
+ .then(data => {
+ if (data.res) setAttachments(data.res)
+ })
+ .catch(() => {})
+ }
// Load default privacy setting for new chores
if (!choreId) {
@@ -941,6 +967,139 @@ const ChoreEdit = () => {
/>
+
+
+ Attachments
+ Files attached to this task
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, idx) => (
+
+
+
+ {att.file_name}
+
+ {att.size_bytes && (
+
+ {(att.size_bytes / 1024).toFixed(1)} KB
+
+ )}
+ {choreId && (
+ {
+ DeleteChoreAttachment(choreId, att.file_path)
+ .then(() => {
+ setAttachments(prev =>
+ prev.filter(a => a.file_path !== att.file_path),
+ )
+ })
+ .catch(() => {
+ showError({
+ title: 'Delete Failed',
+ message: 'Failed to delete attachment.',
+ })
+ })
+ }}
+ >
+
+
+ )}
+ {!choreId && (
+ {
+ setAttachments(prev =>
+ prev.filter((_, i) => i !== idx),
+ )
+ }}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+ }
+ loading={isUploadingAttachment}
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ Upload File
+ {
+ 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)
+ e.target.value = ''
+ }
+ }}
+ />
+
+
+
{/* Section 2: Assignment & Responsibility */}
diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx
index e2629d5..c28eb5b 100644
--- a/src/views/ChoreEdit/ChoreView.jsx
+++ b/src/views/ChoreEdit/ChoreView.jsx
@@ -918,7 +918,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
- dangerouslySetInnerHTML={{ __html: raw }}
+ dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
- dangerouslySetInnerHTML={{ __html: raw }}
+ dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
{
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [attachments, setAttachments] = useState([])
+ const [draftId, setDraftId] = useState(() => crypto.randomUUID())
const [showScan, setShowScan] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
const { isNativeScanner } = useDocumentScanner()
@@ -586,6 +587,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
+ setAttachments([])
+ setDraftId(crypto.randomUUID())
}
const createChore = () => {
@@ -632,7 +635,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
- attachments: attachments.length > 0 ? attachments : null,
+ draftId: draftId,
}
if (frequency) {
@@ -909,7 +912,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
- entityType='chore_attachment'
+ entityType='chore_attachment_draft'
+ draftId={draftId}
/>
{
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
- const { uploadFile } = useFileUpload({ entityType, entityId })
+ const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
useEffect(() => {
if (!isOpen) return
diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx
index adfb070..e776cd3 100644
--- a/src/views/components/RichTextEditor.jsx
+++ b/src/views/components/RichTextEditor.jsx
@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
+
+// Extend the built-in Image blot to preserve dt-data-path
+const ImageBlot = Quill.import('formats/image')
+class DtImageBlot extends ImageBlot {
+ static create(value) {
+ const node = super.create(typeof value === 'string' ? value : value.src)
+ if (value?.path) node.setAttribute('dt-data-path', value.path)
+ return node
+ }
+ static value(node) {
+ return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
+ }
+ static formats(node) {
+ return { 'dt-data-path': node.getAttribute('dt-data-path') }
+ }
+ format(name, value) {
+ if (name === 'dt-data-path') {
+ if (value) this.domNode.setAttribute('dt-data-path', value)
+ else this.domNode.removeAttribute('dt-data-path')
+ } else {
+ super.format(name, value)
+ }
+ }
+}
+DtImageBlot.blotName = 'image'
+DtImageBlot.tagName = 'img'
+Quill.register(DtImageBlot, true)
import {
forwardRef,
useCallback,
@@ -12,7 +39,11 @@ import {
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
-import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
+import {
+ isPlusAccount,
+ refreshSignedUrlsInHtml,
+ resolvePhotoURL,
+} from '../../utils/Helpers'
import './RichTextEditor.css'
const RichTextEditor = forwardRef(
@@ -141,11 +172,16 @@ const RichTextEditor = forwardRef(
return
}
const data = await response.json()
- const url = resolvePhotoURL(data.url || data.sign)
- // Insert image into Quill
+ // Prefer the backend-proxied path (data.sign) over the direct cloud
+ // signed URL (data.url) — the proxy re-signs on every request so the
+ // embedded src never expires.
+ const path = data.path
+ const url = resolvePhotoURL(data.sign || data.url)
+ // Insert image into Quill with dt-data-path tracked by the custom blot
const quill = editorRef.current
const range = quill.getSelection()
- quill.insertEmbed(range ? range.index : 0, 'image', url)
+ const insertIndex = range ? range.index : 0
+ quill.insertEmbed(insertIndex, 'image', { src: url, path })
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({
@@ -227,7 +263,7 @@ const RichTextEditor = forwardRef(
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
- dangerouslySetInnerHTML={{ __html: value }}
+ dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
/>
)
}
diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx
index 3ef1c1d..88bf541 100644
--- a/src/views/components/ScanToTask/ScanPanel.jsx
+++ b/src/views/components/ScanToTask/ScanPanel.jsx
@@ -21,7 +21,7 @@ import { useScanToTask } from './useScanToTask'
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel]
*/
-const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
+const ScanPanel = ({ open, onTaskExtracted, onClose }) => {
const {
isNativeScanner,
phase,
@@ -36,8 +36,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
startCamera,
stopCamera,
capture,
- processImage,
- setCapturedImage,
handleFileSelect,
handleNativeScan,
retake,
@@ -48,23 +46,14 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
// Start/stop based on open state
useEffect(() => {
if (open) {
- if (initialImageUrl) {
- setCapturedImage(initialImageUrl)
- processImage(initialImageUrl, 'browser')
- } else if (isNativeScanner) {
- handleNativeScan().then(result => {
- if (result?.cancelled) onClose()
- })
- } else {
- activate()
- }
+ activate()
} else {
reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
- // Start/stop web camera based on capture phase
+ // Start camera when entering capture phase on web
useEffect(() => {
if (phase === 'capture' && !isNativeScanner) {
startCamera()
diff --git a/src/views/components/ScanToTask/useScanToTask.js b/src/views/components/ScanToTask/useScanToTask.js
index a2178bb..2b5eea4 100644
--- a/src/views/components/ScanToTask/useScanToTask.js
+++ b/src/views/components/ScanToTask/useScanToTask.js
@@ -189,15 +189,14 @@ export function useScanToTask() {
const handleNativeScan = useCallback(async () => {
const { image, cancelled, error } = await scanDocument()
- if (cancelled) return { cancelled: true }
+ if (cancelled) return
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
setPhase('error')
- return { cancelled: false }
+ return
}
setCapturedImage(image)
processImage(image, 'native')
- return { cancelled: false }
}, [scanDocument, processImage])
const retake = useCallback(() => {
@@ -229,7 +228,6 @@ export function useScanToTask() {
isNativeScanner,
phase,
capturedImage,
- setCapturedImage,
ocrProgress,
taskResult,
errorMsg,
@@ -240,7 +238,6 @@ export function useScanToTask() {
startCamera,
stopCamera,
capture,
- processImage,
handleFileSelect,
handleNativeScan,
retake,