Merge pull request #152 from donetick/upload-and-attachment

Upload and attachment
This commit is contained in:
Mohamad Tarbin
2026-07-18 02:28:12 -04:00
committed by GitHub
17 changed files with 684 additions and 195 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "donetick", "name": "donetick",
"version": "1.2.8", "version": "1.2.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "donetick", "name": "donetick",
"version": "1.2.8", "version": "1.2.15",
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "^8.0.0", "@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0", "@capacitor/android": "^8.0.0",

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { patchDescriptionHtml } from '../utils/ImageCache'
// Returns description HTML safe to render: embedded images with an expired
// signed src are swapped for the offline-cached blob or a freshly signed URL.
// Renders the raw HTML immediately and patches in place when needed.
export const useDescriptionHtml = (html, meta = {}) => {
const [patched, setPatched] = useState(html)
useEffect(() => {
let cancelled = false
setPatched(html)
if (!html || !html.includes('dt-data-path')) return undefined
patchDescriptionHtml(html, meta).then(result => {
if (!cancelled && result !== html) setPatched(result)
})
return () => {
cancelled = true
}
// meta is an inline object at call sites; keying on its values would
// re-run every render, so only the html triggers a re-patch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html])
return patched
}

View File

@@ -5,7 +5,11 @@ import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => { export const useFileUpload = ({
entityType = 'chore_attachment',
entityId,
draftId,
} = {}) => {
const { showError } = useNotification() const { showError } = useNotification()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
@@ -76,7 +80,14 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draft
} }
const data = await response.json() const data = await response.json()
return resolvePhotoURL(data.url || data.sign) // url is fetchable now; path is the stable storage key used to
// re-sign, delete, and cache the file later.
return {
url: resolvePhotoURL(data.sign || data.url),
path: data.path,
fileName: data.file_name || file.name,
sizeBytes: data.size_bytes,
}
} catch { } catch {
showError({ showError({
title: 'Upload Failed', title: 'Upload Failed',

View File

@@ -21,6 +21,7 @@ import {
UnArchiveChore, UnArchiveChore,
UpdateChoreHistory, UpdateChoreHistory,
} from '../utils/Fetcher' } from '../utils/Fetcher'
import { cacheChoreImages } from '../utils/ImageCache'
import { offlineDB } from '../utils/OfflineDB' import { offlineDB } from '../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine' import { syncEngine } from '../utils/SyncEngine'
@@ -75,15 +76,20 @@ export const useChores = (includeArchive = false) => {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
queryFn: async () => { queryFn: async () => {
if (isOfflineFeatureEnabled()) { if (isOfflineFeatureEnabled()) {
// Sync from server first (no-op if already syncing or offline) try {
if (networkManager.isOnline) { // Sync from server first (no-op if already syncing or offline)
await syncEngine.sync() if (networkManager.isOnline) {
} await syncEngine.sync()
const cursor = await offlineDB.getSyncCursor() }
if (cursor > 0) { const cursor = await offlineDB.getSyncCursor()
const cached = await offlineDB.getChores(includeArchive) if (cursor > 0) {
const merged = await mergePendingCreates(cached || []) const cached = await offlineDB.getChores(includeArchive)
return { res: merged } const merged = await mergePendingCreates(cached || [])
return { res: merged }
}
} catch (err) {
// A broken cache must not brick the app — fall through to the API
console.error('Offline cache read failed, falling back to API', err)
} }
} }
@@ -337,7 +343,11 @@ export const useChore = choreId => {
try { try {
const response = await GetChoreByID(choreId) const response = await GetChoreByID(choreId)
if (response && response.ok) { if (response && response.ok) {
return await response.json() const data = await response.json()
// Fire-and-forget: store this chore's images (incl. attachments,
// which only appear in detail responses) for offline use
if (data?.res) cacheChoreImages(data.res)
return data
} }
throw new Error('Failed to fetch chore') throw new Error('Failed to fetch chore')
} catch { } catch {
@@ -595,9 +605,7 @@ export const useMarkChoreComplete = () => {
if (!oldData) return oldData if (!oldData) return oldData
return { return {
res: oldData.res.map(chore => res: oldData.res.map(chore =>
chore.id === choreId chore.id === choreId ? { ...chore, _pending: 'complete' } : chore,
? { ...chore, _pending: 'complete' }
: chore,
), ),
} }
}) })

View File

@@ -138,6 +138,13 @@ class ApiClient {
} catch (e) { } catch (e) {
console.error('Error clearing offline data on logout', e) console.error('Error clearing offline data on logout', e)
} }
try {
// Dynamic import sidesteps the ApiClient <-> ImageCache module cycle
const { clearImageCache } = await import('./ImageCache')
await clearImageCache()
} catch (e) {
console.error('Error clearing image cache on logout', e)
}
try { try {
await logout() await logout()
} catch (e) { } catch (e) {
@@ -252,7 +259,7 @@ class ApiClient {
} }
return this.request(endpoint, { return this.request(endpoint, {
options: { ...options }, ...options,
method: 'POST', method: 'POST',
body: data ? data : undefined, body: data ? data : undefined,
}) })

View File

@@ -17,6 +17,17 @@ export const CommandType = {
UNARCHIVE_CHORE: 'unarchive_chore', UNARCHIVE_CHORE: 'unarchive_chore',
} }
// Parse a stored command payload; a corrupt payload must not take down every
// consumer of the queue, so parse failures surface as null payloads.
const parsePayload = command => {
try {
return { ...command, payload: JSON.parse(command.payload) }
} catch {
console.warn('Skipping corrupt command payload', command.id)
return null
}
}
class CommandQueue { class CommandQueue {
_sanitizeCreatePayload(payload = {}) { _sanitizeCreatePayload(payload = {}) {
const sanitized = { ...payload } const sanitized = { ...payload }
@@ -77,7 +88,8 @@ class CommandQueue {
const commands = await offlineDB.getCommands() const commands = await offlineDB.getCommands()
return commands return commands
.filter(c => c.status === 'pending' || c.status === 'syncing') .filter(c => c.status === 'pending' || c.status === 'syncing')
.map(c => ({ ...c, payload: JSON.parse(c.payload) })) .map(parsePayload)
.filter(Boolean)
} }
// Get all failed commands // Get all failed commands
@@ -86,7 +98,8 @@ class CommandQueue {
const commands = await offlineDB.getCommands() const commands = await offlineDB.getCommands()
return commands return commands
.filter(c => c.status === 'failed') .filter(c => c.status === 'failed')
.map(c => ({ ...c, payload: JSON.parse(c.payload) })) .map(parsePayload)
.filter(Boolean)
} }
// Get pending commands for a specific entity (for undo/UI) // Get pending commands for a specific entity (for undo/UI)
@@ -103,7 +116,8 @@ class CommandQueue {
.sort((a, b) => a.createdAt - b.createdAt) .sort((a, b) => a.createdAt - b.createdAt)
return commands return commands
.filter(c => c.status === 'pending' || c.status === 'syncing') .filter(c => c.status === 'pending' || c.status === 'syncing')
.map(c => ({ ...c, payload: JSON.parse(c.payload) })) .map(parsePayload)
.filter(Boolean)
} }
// Cancel/undo a pending command // Cancel/undo a pending command

View File

@@ -728,7 +728,11 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
}) })
} }
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => { const UploadChoreAttachment = (
file,
entityType,
{ entityId, draftId } = {},
) => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('entityType', entityType) formData.append('entityType', entityType)
@@ -737,6 +741,22 @@ const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) =>
return apiClient.upload('/assets/chore', formData) return apiClient.upload('/assets/chore', formData)
} }
const DeleteDraftAttachment = filePath => {
return Fetch(`/assets/chore`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({ file_path: filePath }),
})
}
// Returns a fresh signed URL for a stored asset path the current user may access.
const SignAssetURL = path => {
return Fetch(`/files/sign?path=${encodeURIComponent(path)}`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetChoreAttachments = choreId => { const GetChoreAttachments = choreId => {
return Fetch(`/chores/${choreId}/attachments`, { return Fetch(`/chores/${choreId}/attachments`, {
method: 'GET', method: 'GET',
@@ -958,7 +978,9 @@ const TrackFilterUsage = id => {
export { export {
AcceptCircleMemberRequest, AcceptCircleMemberRequest,
DeleteChoreAttachment, DeleteChoreAttachment,
DeleteDraftAttachment,
GetChoreAttachments, GetChoreAttachments,
SignAssetURL,
UploadChoreAttachment, UploadChoreAttachment,
ApproveChore, ApproveChore,
ArchiveChore, ArchiveChore,
@@ -1060,6 +1082,5 @@ export {
UpdateThingState, UpdateThingState,
UpdateTimeSession, UpdateTimeSession,
UpdateUserDetails, UpdateUserDetails,
VerifyMFA VerifyMFA,
} }

View File

@@ -13,111 +13,74 @@ const resolvePhotoURL = url => {
return apiClient.getAssetURL(url) return apiClient.getAssetURL(url)
} }
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params. // Returns the expiry of a signed asset URL in epoch ms, or null when the URL
const isCloudSignedUrl = url => { // carries no expiry (public assets, plain paths, OIDC picture URLs).
if (!url) return false // Understands the backend's local signer (`expires` in unix seconds) and
// S3 presigned URLs (`X-Amz-Date` + `X-Amz-Expires`).
const getSignedUrlExpiry = url => {
if (!url) return null
try { try {
return ( const query = new URL(url, 'http://relative.local').searchParams
url.includes('X-Amz-Signature') || if (query.has('expires')) {
url.includes('X-Amz-Expires') || const expires = parseInt(query.get('expires'), 10)
url.includes('X-Goog-Expires') || return Number.isFinite(expires) ? expires * 1000 : null
url.includes('expires') }
) if (query.has('X-Amz-Expires') && query.has('X-Amz-Date')) {
} catch(e) { const iso = query
return false .get('X-Amz-Date')
} .replace(
} /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/,
'$1-$2-$3T$4:$5:$6Z',
// 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). const start = Date.parse(iso)
// const validFor = parseInt(query.get('X-Amz-Expires'), 10)
// Handles: if (Number.isFinite(start) && Number.isFinite(validFor)) {
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?... return start + validFor * 1000
// 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
} }
return null
// 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 { } catch {
return null return null
} }
} }
// Scan an HTML string for <img> tags whose src is a cloud signed URL and // One minute of clock skew so we refresh before the server starts rejecting.
// replace them with backend proxy URLs (which generate fresh signed URLs on const isSignedUrlExpired = url => {
// each request). Returns the patched HTML, or the original if nothing changed. const expiry = getSignedUrlExpiry(url)
const refreshSignedUrlsInHtml = html => { return expiry != null && Date.now() > expiry - 60_000
if (!html) return html
if (
!html.includes('dt-data-path') &&
!html.includes('X-Amz-') &&
!html.includes('X-Goog-') &&
!html.includes('sig') &&
!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 stablePath = img.getAttribute('dt-data-path')
const src = img.getAttribute('src')
let nextSrc = src
if (stablePath) {
nextSrc = resolvePhotoURL(stablePath)
} else if (isCloudSignedUrl(src)) {
nextSrc = resolvePhotoURL(extractStorageKey(src))
}
if (nextSrc && nextSrc !== src) {
img.setAttribute('src', nextSrc)
changed = true
}
})
return changed ? doc.body.innerHTML : html
} }
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL } // Fetches a fresh signed URL for a stored asset path via the authed sign
// endpoint. Memoized per path until the signed URL nears expiry, with
// in-flight de-duplication so a burst of images signs each path once.
const signedUrlCache = new Map()
const getSignedAssetUrl = async path => {
if (!path) return ''
const hit = signedUrlCache.get(path)
if (hit?.url && !isSignedUrlExpired(hit.url)) return hit.url
if (hit?.promise) return hit.promise
const promise = (async () => {
const response = await apiClient.get(
`/files/sign?path=${encodeURIComponent(path)}`,
)
if (!response?.ok) {
throw new Error(`Failed to sign asset path: ${path}`)
}
const data = await response.json()
const url = resolvePhotoURL(data.url)
signedUrlCache.set(path, { url })
return url
})()
promise.catch(() => signedUrlCache.delete(path))
signedUrlCache.set(path, { promise })
return promise
}
export {
getSignedAssetUrl,
getSignedUrlExpiry,
isPlusAccount,
isSignedUrlExpired,
resolvePhotoURL,
}

301
src/utils/ImageCache.js Normal file
View File

@@ -0,0 +1,301 @@
import {
getSignedAssetUrl,
isSignedUrlExpired,
resolvePhotoURL,
} from './Helpers'
// Offline image store keyed by the *stable* storage path (never the signed
// URL, whose query params change on every re-sign). Backed by the Cache
// Storage API, which works in browsers and the Capacitor WebView alike.
//
// Lifecycle: an image stays cached for as long as something still references
// it. syncOfflineImages() reconciles the store against the freshly synced
// chore data — prefetching referenced images that are missing and evicting
// entries whose chore is gone or whose description no longer embeds them.
// Attachment entries are evicted when the attachment (or its chore) is
// deleted. Everything is dropped on logout.
const CACHE_NAME = 'dt-images-v1'
const MANIFEST_KEY = 'dt-image-cache-manifest'
const hasCacheSupport = () =>
typeof caches !== 'undefined' && typeof window !== 'undefined'
// Cache API keys must be request URLs; keep them under a reserved fake path.
const keyForPath = path => `/__dt-image-cache__/${encodeURI(path)}`
// Manifest maps path -> { choreId, kind: 'description' | 'attachment', cachedAt }.
// It exists so eviction can reason about *why* an image was cached.
const loadManifest = () => {
try {
return JSON.parse(localStorage.getItem(MANIFEST_KEY)) || {}
} catch {
return {}
}
}
const saveManifest = manifest => {
try {
localStorage.setItem(MANIFEST_KEY, JSON.stringify(manifest))
} catch {
// localStorage full or unavailable — cache still works, eviction degrades
}
}
// Object URLs are memoized per path so repeated renders reuse one blob URL.
const objectUrlByPath = new Map()
const getCachedImageUrl = async path => {
if (!path || !hasCacheSupport()) return null
if (objectUrlByPath.has(path)) return objectUrlByPath.get(path)
try {
const cache = await caches.open(CACHE_NAME)
const response = await cache.match(keyForPath(path))
if (!response) return null
const blob = await response.blob()
const url = URL.createObjectURL(blob)
objectUrlByPath.set(path, url)
return url
} catch {
return null
}
}
const isImageCached = async path => {
if (!path || !hasCacheSupport()) return false
if (objectUrlByPath.has(path)) return true
try {
const cache = await caches.open(CACHE_NAME)
return !!(await cache.match(keyForPath(path)))
} catch {
return false
}
}
// Downloads fetchUrl and stores it under the stable path. meta records the
// owning chore so eviction can drop the entry when the reference goes away.
const cacheImageFromUrl = async (path, fetchUrl, meta = {}) => {
if (!path || !fetchUrl || !hasCacheSupport()) return false
try {
const response = await fetch(fetchUrl)
if (!response.ok) return false
const cache = await caches.open(CACHE_NAME)
await cache.put(keyForPath(path), response)
const manifest = loadManifest()
manifest[path] = { ...manifest[path], ...meta, cachedAt: Date.now() }
saveManifest(manifest)
// Invalidate any stale object URL for this path
const stale = objectUrlByPath.get(path)
if (stale) {
URL.revokeObjectURL(stale)
objectUrlByPath.delete(path)
}
return true
} catch {
return false
}
}
const removeCachedImage = async path => {
if (!path || !hasCacheSupport()) return
try {
const cache = await caches.open(CACHE_NAME)
await cache.delete(keyForPath(path))
} catch {
// best effort
}
const manifest = loadManifest()
if (manifest[path]) {
delete manifest[path]
saveManifest(manifest)
}
const url = objectUrlByPath.get(path)
if (url) {
URL.revokeObjectURL(url)
objectUrlByPath.delete(path)
}
}
const clearImageCache = async () => {
if (hasCacheSupport()) {
try {
await caches.delete(CACHE_NAME)
} catch {
// best effort
}
}
try {
localStorage.removeItem(MANIFEST_KEY)
} catch {
// ignore
}
objectUrlByPath.forEach(url => URL.revokeObjectURL(url))
objectUrlByPath.clear()
}
// Extracts (path, src) pairs for every dt-data-path image embedded in a
// description HTML string. Attribute values are HTML-escaped in the raw
// string ("&amp;"), so src is decoded for fetching while rawSrc keeps the
// exact attribute text for string replacement.
const extractDescriptionImages = html => {
if (!html || !html.includes('dt-data-path')) return []
const results = []
const imgReg = /<img[^>]+>/g
const pathReg = /dt-data-path="([^"]+)"/
const srcReg = /src="([^"]*)"/
const decodeAttr = value => (value ? value.replaceAll('&amp;', '&') : value)
for (const tag of html.match(imgReg) || []) {
const path = tag.match(pathReg)?.[1]
if (!path) continue
const rawSrc = tag.match(srcReg)?.[1] || null
results.push({ path: decodeAttr(path), src: decodeAttr(rawSrc), rawSrc })
}
return results
}
// Collects every image reference in a chore: description embeds and (when
// present, i.e. detail responses) attachments. path -> { choreId, kind, src }.
const collectChoreImageRefs = (chore, referenced = new Map()) => {
if (chore?.id == null) return referenced
for (const { path, src } of extractDescriptionImages(chore.description)) {
referenced.set(path, {
choreId: String(chore.id),
kind: 'description',
src,
})
}
for (const att of chore.attachments || []) {
if (att?.file_path) {
referenced.set(att.file_path, {
choreId: String(chore.id),
kind: 'attachment',
src: att.sign ? resolvePhotoURL(att.sign) : null,
})
}
}
return referenced
}
// Downloads every referenced image that is not stored yet.
const prefetchReferenced = async referenced => {
for (const [path, { choreId, kind, src }] of referenced) {
if (await isImageCached(path)) {
// keep manifest ownership fresh (chore may have changed via clone etc.)
const current = loadManifest()
if (
current[path] &&
(current[path].choreId !== choreId || current[path].kind !== kind)
) {
current[path] = { ...current[path], choreId, kind }
saveManifest(current)
}
continue
}
let fetchUrl = src && !isSignedUrlExpired(src) ? src : null
if (!fetchUrl) {
try {
fetchUrl = await getSignedAssetUrl(path)
} catch {
continue
}
}
await cacheImageFromUrl(path, fetchUrl, { choreId, kind })
}
}
// Stores one chore's images for offline use without evicting anything.
// Call with detail responses, which are the only place attachments appear.
const cacheChoreImages = async chore => {
if (!hasCacheSupport()) return
try {
await prefetchReferenced(collectChoreImageRefs(chore))
} catch (err) {
console.error('Failed to cache chore images', err)
}
}
// Reconciles the image store with the *complete* synced chore list: every
// image the chores still reference gets downloaded (so offline has
// everything), and cached description images whose chore or reference
// disappeared are evicted. Attachments are only referenced from chore-detail
// responses, so they are evicted here only when their whole chore is gone.
const syncOfflineImages = async chores => {
if (!hasCacheSupport() || !Array.isArray(chores)) return
try {
const referenced = new Map()
const choreIds = new Set()
for (const chore of chores) {
if (chore?.id == null) continue
choreIds.add(String(chore.id))
collectChoreImageRefs(chore, referenced)
}
// Evict entries that are provably no longer referenced.
const manifest = loadManifest()
for (const [path, entry] of Object.entries(manifest)) {
if (!entry?.choreId) continue
const choreGone = !choreIds.has(String(entry.choreId))
const droppedFromDescription =
entry.kind === 'description' &&
choreIds.has(String(entry.choreId)) &&
!referenced.has(path)
if (choreGone || droppedFromDescription) {
await removeCachedImage(path)
}
}
await prefetchReferenced(referenced)
} catch (err) {
console.error('Offline image sync failed', err)
}
}
// Returns a displayable src for a stored asset path: the still-valid signed
// src when one is provided, otherwise the cached blob, otherwise a freshly
// signed URL. Opportunistically stores the image for offline use.
const getImageSrc = async (path, signedSrc = null, meta = {}) => {
if (signedSrc && !isSignedUrlExpired(signedSrc)) {
if (!(await isImageCached(path))) {
cacheImageFromUrl(path, signedSrc, meta) // fire and forget
}
return signedSrc
}
const cached = await getCachedImageUrl(path)
if (cached) return cached
const fresh = await getSignedAssetUrl(path)
cacheImageFromUrl(path, fresh, meta) // fire and forget
return fresh
}
// Rewrites dt-data-path images in a description HTML string so every img has
// a usable src: valid signed srcs are kept (and stored for offline), expired
// ones are swapped for the cached blob or a freshly signed URL.
const patchDescriptionHtml = async (html, meta = {}) => {
const images = extractDescriptionImages(html)
if (images.length === 0) return html
let patched = html
for (const { path, src, rawSrc } of images) {
try {
const nextSrc = await getImageSrc(path, src, {
kind: 'description',
...meta,
})
if (nextSrc && nextSrc !== src) {
patched = patched.replaceAll(`src="${rawSrc}"`, `src="${nextSrc}"`)
}
} catch {
// offline with no cache entry — leave the original src in place
}
}
return patched
}
export {
cacheChoreImages,
cacheImageFromUrl,
clearImageCache,
getCachedImageUrl,
getImageSrc,
patchDescriptionHtml,
removeCachedImage,
syncOfflineImages,
}

View File

@@ -20,6 +20,29 @@ const isNative = () => {
return _isNative return _isNative
} }
// The raw CapacitorSQLite query API prepends a metadata row on iOS
// (e.g. { ios_columns: ["data"] }). The plugin's SQLiteDBConnection wrapper
// strips it, but we call the plugin directly, so filter it here — otherwise
// the metadata row reaches JSON.parse(undefined) and crashes offline reads.
const queryRows = result =>
(result?.values || []).filter(
row => row && typeof row === 'object' && !('ios_columns' in row),
)
// Parse a JSON column across rows, skipping corrupt rows instead of letting
// one bad row take down the whole offline cache.
const parseJsonRows = (result, column) => {
const parsed = []
for (const row of queryRows(result)) {
try {
parsed.push(JSON.parse(row[column]))
} catch (err) {
console.warn('Skipping corrupt offline cache row', err)
}
}
return parsed
}
// ── SQLite backend (iOS/Android) ── // ── SQLite backend (iOS/Android) ──
class SQLiteBackend { class SQLiteBackend {
@@ -125,7 +148,7 @@ class SQLiteBackend {
statement: 'SELECT data FROM cached_chores', statement: 'SELECT data FROM cached_chores',
values: [], values: [],
}) })
const chores = (result.values || []).map(row => JSON.parse(row.data)) const chores = parseJsonRows(result, 'data')
if (includeArchive) { if (includeArchive) {
return chores return chores
} }
@@ -139,10 +162,7 @@ class SQLiteBackend {
statement: 'SELECT data FROM cached_chores WHERE id = ?', statement: 'SELECT data FROM cached_chores WHERE id = ?',
values: [isNaN(numericId) ? id : numericId], values: [isNaN(numericId) ? id : numericId],
}) })
if (result.values && result.values.length > 0) { return parseJsonRows(result, 'data')[0] ?? null
return JSON.parse(result.values[0].data)
}
return null
} }
async deleteChores(ids) { async deleteChores(ids) {
@@ -216,7 +236,7 @@ class SQLiteBackend {
'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC', 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC',
values: [Number(choreId)], values: [Number(choreId)],
}) })
return (result.values || []).map(row => JSON.parse(row.data)) return parseJsonRows(result, 'data')
} }
async getHistoryByDays(days) { async getHistoryByDays(days) {
@@ -229,7 +249,7 @@ class SQLiteBackend {
: 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC', : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC',
values: since === 0 ? [] : [since], values: since === 0 ? [] : [since],
}) })
return (result.values || []).map(row => JSON.parse(row.data)) return parseJsonRows(result, 'data')
} }
async deleteHistory(ids) { async deleteHistory(ids) {
@@ -248,10 +268,16 @@ class SQLiteBackend {
values: [historyId], values: [historyId],
}) })
if (!existing.values?.length) return const existingRows = queryRows(existing)
if (!existingRows.length) return
const row = existing.values[0] const row = existingRows[0]
const current = JSON.parse(row.data) let current
try {
current = JSON.parse(row.data)
} catch {
return
}
const merged = { const merged = {
...current, ...current,
...updates, ...updates,
@@ -314,7 +340,7 @@ class SQLiteBackend {
statement: 'SELECT * FROM command_queue ORDER BY created_at ASC', statement: 'SELECT * FROM command_queue ORDER BY created_at ASC',
values: [], values: [],
}) })
return (result.values || []).map(row => ({ return queryRows(result).map(row => ({
id: row.id, id: row.id,
commandType: row.command_type, commandType: row.command_type,
entityId: row.entity_id, entityId: row.entity_id,
@@ -332,7 +358,7 @@ class SQLiteBackend {
'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC', 'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC',
values: [entityId], values: [entityId],
}) })
return (result.values || []).map(row => ({ return queryRows(result).map(row => ({
id: row.id, id: row.id,
commandType: row.command_type, commandType: row.command_type,
entityId: row.entity_id, entityId: row.entity_id,
@@ -358,9 +384,10 @@ class SQLiteBackend {
values: [id], values: [id],
}) })
if (!result.values?.length) return const resultRows = queryRows(result)
if (!resultRows.length) return
const row = result.values[0] const row = resultRows[0]
await CapacitorSQLite.run({ await CapacitorSQLite.run({
database: DB_NAME, database: DB_NAME,
statement: `UPDATE command_queue statement: `UPDATE command_queue
@@ -403,8 +430,10 @@ class SQLiteBackend {
statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'", statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'",
values: [], values: [],
}) })
if (result.values && result.values.length > 0) { const rows = queryRows(result)
return Number(result.values[0].value) if (rows.length > 0) {
const cursor = Number(rows[0].value)
return Number.isFinite(cursor) ? cursor : 0
} }
return 0 return 0
} }
@@ -424,8 +453,10 @@ class SQLiteBackend {
statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'", statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'",
values: [], values: [],
}) })
if (result.values && result.values.length > 0) { const rows = queryRows(result)
return Number(result.values[0].value) if (rows.length > 0) {
const time = Number(rows[0].value)
return Number.isFinite(time) ? time : null
} }
return null return null
} }
@@ -453,9 +484,10 @@ class SQLiteBackend {
statement: 'SELECT value FROM sync_meta WHERE key = ?', statement: 'SELECT value FROM sync_meta WHERE key = ?',
values: [key], values: [key],
}) })
if (result.values && result.values.length > 0) { const rows = queryRows(result)
if (rows.length > 0) {
try { try {
return JSON.parse(result.values[0].value) return JSON.parse(rows[0].value)
} catch { } catch {
return null return null
} }

View File

@@ -15,6 +15,7 @@ import {
UpdateChoreHistory, UpdateChoreHistory,
UpdateDueDate, UpdateDueDate,
} from './Fetcher' } from './Fetcher'
import { syncOfflineImages } from './ImageCache'
import { offlineDB } from './OfflineDB' import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
@@ -58,6 +59,13 @@ class SyncEngine {
// Sync succeeded — server is reachable (only sync success restores online status) // Sync succeeded — server is reachable (only sync success restores online status)
networkManager.setServerReachable() networkManager.setServerReachable()
this._notify({ syncing: false, lastSync: Date.now() }) this._notify({ syncing: false, lastSync: Date.now() })
// Reconcile the offline image store against the full cached chore list
// (prefetch referenced images, evict ones no longer referenced).
// Fire-and-forget: image downloads must not block or fail the sync.
offlineDB
.getChores(true)
.then(chores => syncOfflineImages(chores || []))
.catch(() => {})
return true return true
} catch (err) { } catch (err) {
await commandQueue.resetSyncing() await commandQueue.resetSyncing()
@@ -242,6 +250,13 @@ class SyncEngine {
if (!isOfflineFeatureEnabled()) return if (!isOfflineFeatureEnabled()) return
if (!chores || chores.length === 0) return if (!chores || chores.length === 0) return
await offlineDB.saveChores(chores) await offlineDB.saveChores(chores)
// Fire-and-forget: keep the offline image store in step with the data.
// Reconcile against the *full* cached list — the passed list may exclude
// archived chores, and eviction must only run against everything we have.
offlineDB
.getChores(true)
.then(all => syncOfflineImages(all || []))
.catch(() => {})
} }
} }

View File

@@ -53,11 +53,13 @@ import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { import {
DeleteChoreAttachment, DeleteChoreAttachment,
DeleteDraftAttachment,
GetAllCircleMembers, GetAllCircleMembers,
GetThings, GetThings,
UploadChoreAttachment, UploadChoreAttachment,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
import { generateUUID } from '../../utils/UUID' import { generateUUID } from '../../utils/UUID'
import Priorities from '../../utils/Priorities.jsx' import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons' import { getIconComponent } from '../../utils/ProjectIcons'
@@ -725,6 +727,7 @@ const ChoreEdit = () => {
onChange={setDescription} onChange={setDescription}
entityId={choreId} entityId={choreId}
entityType={'chore_description'} entityType={'chore_description'}
draftId={draftId}
/> />
<FormHelperText error>{errors.description}</FormHelperText> <FormHelperText error>{errors.description}</FormHelperText>
</FormControl> </FormControl>
@@ -970,12 +973,23 @@ const ChoreEdit = () => {
<Typography level='body-md'>Files attached to this task</Typography> <Typography level='body-md'>Files attached to this task</Typography>
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}> <Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
{attachments.length > 0 && ( {attachments.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}> <Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
mb: 1.5,
}}
>
{attachments.map((att, idx) => ( {attachments.map((att, idx) => (
<Box <Box
key={att.file_path || idx} key={att.file_path || idx}
onClick={() => { onClick={async () => {
const url = resolvePhotoURL(att.sign || att.file_path) const url = await getImageSrc(
att.file_path,
att.sign ? resolvePhotoURL(att.sign) : null,
{ choreId, kind: 'attachment' },
).catch(() => resolvePhotoURL(att.sign || att.file_path))
const ext = (att.file_name || '') const ext = (att.file_name || '')
.split('.') .split('.')
.pop() .pop()
@@ -1039,6 +1053,7 @@ const ChoreEdit = () => {
event.stopPropagation() event.stopPropagation()
DeleteChoreAttachment(choreId, att.file_path) DeleteChoreAttachment(choreId, att.file_path)
.then(() => { .then(() => {
removeCachedImage(att.file_path)
setAttachments(prev => setAttachments(prev =>
prev.filter(a => a.file_path !== att.file_path), prev.filter(a => a.file_path !== att.file_path),
) )
@@ -1061,9 +1076,20 @@ const ChoreEdit = () => {
color='danger' color='danger'
onClick={event => { onClick={event => {
event.stopPropagation() event.stopPropagation()
setAttachments(prev => // Draft uploads live server-side too — delete there
prev.filter((_, i) => i !== idx), // so they are not promoted onto the chore on save.
) DeleteDraftAttachment(att.file_path)
.then(() => {
setAttachments(prev =>
prev.filter((_, i) => i !== idx),
)
})
.catch(() => {
showError({
title: 'Delete Failed',
message: 'Failed to delete attachment.',
})
})
}} }}
> >
<Delete sx={{ fontSize: 18 }} /> <Delete sx={{ fontSize: 18 }} />
@@ -1078,9 +1104,7 @@ const ChoreEdit = () => {
variant='outlined' variant='outlined'
color='neutral' color='neutral'
size='sm' size='sm'
startDecorator={ startDecorator={isUploadingAttachment ? null : <UploadFile />}
isUploadingAttachment ? null : <UploadFile />
}
loading={isUploadingAttachment} loading={isUploadingAttachment}
sx={{ alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >

View File

@@ -88,7 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx' import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx' import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx' import TimerSplitButton from './TimerSplitButton.jsx'
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx' import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
const isNetworkError = err => const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch' err instanceof TypeError && err.message === 'Failed to fetch'
@@ -132,6 +132,12 @@ const ChoreView = () => {
useCircleMembers() useCircleMembers()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser() const { impersonatedUser } = useImpersonateUser()
const descriptionHtml = useDescriptionHtml(chore?.description || '', {
choreId: chore?.id,
})
const notesHtml = useDescriptionHtml(chore?.notes || '', {
choreId: chore?.id,
})
const { data: choreData, isLoading: isChoreLoading } = const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId) useChoreDetails(choreId)
@@ -935,7 +941,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }} dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/> />
) : ( ) : (
<Typography <Typography
@@ -1003,7 +1009,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }} dangerouslySetInnerHTML={{ __html: notesHtml }}
/> />
) : ( ) : (
<Typography <Typography
@@ -1099,6 +1105,7 @@ const ChoreView = () => {
<RichTextEditor <RichTextEditor
value={note || ''} value={note || ''}
onChange={setNote} onChange={setNote}
entityId={chore?.id}
entityType={'chore_completion_note'} entityType={'chore_completion_note'}
placeholder={t('choreView.notePlaceholder')} placeholder={t('choreView.notePlaceholder')}
/> />

View File

@@ -1,9 +1,18 @@
import { AttachFile, Close, Image } from '@mui/icons-material' import { AttachFile, Close, Image } from '@mui/icons-material'
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy' import {
Box,
Button,
CircularProgress,
List,
ListItem,
ListItemButton,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher' import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers' import { resolvePhotoURL } from '../../../utils/Helpers'
import { cacheChoreImages, getImageSrc } from '../../../utils/ImageCache'
import AttachmentViewerModal from './AttachmentViewerModal' import AttachmentViewerModal from './AttachmentViewerModal'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
@@ -38,7 +47,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
if (!res.ok) throw new Error('Failed to fetch attachments') if (!res.ok) throw new Error('Failed to fetch attachments')
return res.json() return res.json()
}) })
.then(data => setAttachments(Array.isArray(data) ? data : [])) .then(data => {
const list = Array.isArray(data) ? data : []
setAttachments(list)
// Fire-and-forget: store attachments so they open offline later
cacheChoreImages({ id: choreId, attachments: list })
})
.catch(() => setAttachments([])) .catch(() => setAttachments([]))
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
}, [isOpen, choreId]) }, [isOpen, choreId])
@@ -48,8 +62,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
onClose?.() onClose?.()
} }
const handleAttachmentClick = attachment => { const handleAttachmentClick = async attachment => {
const url = resolvePhotoURL(attachment.sign) const url = await getImageSrc(
attachment.file_path,
attachment.sign ? resolvePhotoURL(attachment.sign) : null,
{ choreId: String(choreId), kind: 'attachment' },
).catch(() => resolvePhotoURL(attachment.sign))
if (isImageFile(attachment.file_name)) { if (isImageFile(attachment.file_name)) {
setViewerConfig({ setViewerConfig({
isOpen: true, isOpen: true,
@@ -119,7 +137,10 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
{attachment.file_name || `File ${index + 1}`} {attachment.file_name || `File ${index + 1}`}
</Typography> </Typography>
{attachment.size_bytes > 0 && ( {attachment.size_bytes > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}> <Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
{(attachment.size_bytes / 1024).toFixed(1)} KB {(attachment.size_bytes / 1024).toFixed(1)} KB
</Typography> </Typography>
)} )}

View File

@@ -1078,6 +1078,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
onChange={setDescription} onChange={setDescription}
value={description || ''} value={description || ''}
entityType={'chore_description'} entityType={'chore_description'}
draftId={draftId}
/> />
</div> </div>
</Box> </Box>

View File

@@ -11,6 +11,7 @@ import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex' import { Z_INDEX } from '../../constants/zIndex'
import { useFileUpload } from '../../hooks/useFileUpload' import { useFileUpload } from '../../hooks/useFileUpload'
import { DeleteDraftAttachment } from '../../utils/Fetcher'
const AttachmentPickerField = ({ const AttachmentPickerField = ({
attachments = [], attachments = [],
@@ -45,9 +46,12 @@ const AttachmentPickerField = ({
if (!file) return if (!file) return
setIsUploading(true) setIsUploading(true)
try { try {
const url = await uploadFile(file) const uploaded = await uploadFile(file)
if (url) { if (uploaded) {
onChange([...attachments, { url, name: file.name }]) onChange([
...attachments,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
} }
} finally { } finally {
setIsUploading(false) setIsUploading(false)
@@ -55,7 +59,17 @@ const AttachmentPickerField = ({
} }
} }
const handleRemove = index => { const handleRemove = async index => {
const attachment = attachments[index]
// Draft uploads exist server-side too — delete there so they are not
// promoted onto the chore when it is created.
if (attachment?.path) {
try {
await DeleteDraftAttachment(attachment.path)
} catch {
// file may already be gone; still drop it from the list
}
}
const updated = attachments.filter((_, i) => i !== index) const updated = attachments.filter((_, i) => i !== index)
onChange(updated) onChange(updated)
if (updated.length === 0) setIsOpen(false) if (updated.length === 0) setIsOpen(false)
@@ -91,7 +105,10 @@ const AttachmentPickerField = ({
}} }}
> >
{isUploading ? ( {isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} /> <CircularProgress
size='sm'
sx={{ '--CircularProgress-size': '16px' }}
/>
) : ( ) : (
<AttachFile sx={{ fontSize: '20px' }} /> <AttachFile sx={{ fontSize: '20px' }} />
)} )}
@@ -162,7 +179,14 @@ const AttachmentPickerField = ({
}} }}
> >
{attachments.length > 0 && ( {attachments.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box
sx={{
mb: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
<Box <Box
key={index} key={index}
@@ -238,7 +262,10 @@ const AttachmentPickerField = ({
color='neutral' color='neutral'
startDecorator={ startDecorator={
isUploading ? ( isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} /> <CircularProgress
size='sm'
sx={{ '--CircularProgress-size': '14px' }}
/>
) : ( ) : (
<AttachFile sx={{ fontSize: 16 }} /> <AttachFile sx={{ fontSize: 16 }} />
) )

View File

@@ -12,7 +12,10 @@ class DtImageBlot extends ImageBlot {
return node return node
} }
static value(node) { static value(node) {
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') } return {
src: node.getAttribute('src'),
path: node.getAttribute('dt-data-path'),
}
} }
static formats(node) { static formats(node) {
return { 'dt-data-path': node.getAttribute('dt-data-path') } return { 'dt-data-path': node.getAttribute('dt-data-path') }
@@ -36,14 +39,12 @@ import {
useImperativeHandle, useImperativeHandle,
useRef, useRef,
} from 'react' } from 'react'
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient' import { apiClient } from '../../utils/ApiClient'
import { import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
isPlusAccount, import { patchDescriptionHtml } from '../../utils/ImageCache'
refreshSignedUrlsInHtml,
resolvePhotoURL,
} from '../../utils/Helpers'
import './RichTextEditor.css' import './RichTextEditor.css'
const RichTextEditor = forwardRef( const RichTextEditor = forwardRef(
@@ -56,11 +57,14 @@ const RichTextEditor = forwardRef(
variant = 'outlined', variant = 'outlined',
entityId, entityId,
entityType, entityType,
draftId,
}, },
ref, ref,
) => { ) => {
const { showError } = useNotification() const { showError } = useNotification()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
// Display-only HTML with expired image srcs swapped for cached/re-signed ones
const displayHtml = useDescriptionHtml(value)
const quillRef = useRef(null) const quillRef = useRef(null)
const editorRef = useRef(null) const editorRef = useRef(null)
const initialContentSet = useRef(false) const initialContentSet = useRef(false)
@@ -132,11 +136,20 @@ const RichTextEditor = forwardRef(
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`, `Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
) )
// Upload compressed image to backend // Upload compressed image to backend. Without a saved entity yet,
// upload as a draft tied to draftId — the backend promotes drafts
// to the real entity when the chore is created.
const formData = new FormData() const formData = new FormData()
formData.append('file', compressedJpegFile) formData.append('file', compressedJpegFile)
formData.append('entityId', entityId) if (entityId) {
formData.append('entityType', entityType) formData.append('entityId', String(entityId))
formData.append('entityType', entityType)
} else if (draftId) {
formData.append('entityType', `${entityType}_draft`)
formData.append('draftId', draftId)
} else {
formData.append('entityType', entityType)
}
const response = await apiClient.upload('/assets/chore', formData) const response = await apiClient.upload('/assets/chore', formData)
@@ -152,7 +165,7 @@ const RichTextEditor = forwardRef(
message: 'The file you are trying to upload is too large.', message: 'The file you are trying to upload is too large.',
}) })
return return
} else if (response.status === 403 && !isPlusAccount()) { } else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({ showError({
title: 'Upgrade Required', title: 'Upgrade Required',
message: message:
@@ -173,9 +186,9 @@ const RichTextEditor = forwardRef(
return return
} }
const data = await response.json() const data = await response.json()
// Prefer the backend-proxied path (data.sign) over the direct cloud // data.sign is a fetchable signed URL; data.path is the stable
// signed URL (data.url) — the proxy re-signs on every request so the // storage key kept in dt-data-path so the src can be re-signed
// embedded src never expires. // after the URL expires.
const path = data.path const path = data.path
const url = resolvePhotoURL(data.sign || data.url) const url = resolvePhotoURL(data.sign || data.url)
// Insert image into Quill with dt-data-path tracked by the custom blot // Insert image into Quill with dt-data-path tracked by the custom blot
@@ -191,7 +204,7 @@ const RichTextEditor = forwardRef(
}) })
} }
} }
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback }, [entityId, entityType, draftId, showError, userProfile]) // Dependencies for useCallback
useEffect(() => { useEffect(() => {
if (!quillRef.current) return if (!quillRef.current) return
@@ -223,27 +236,25 @@ const RichTextEditor = forwardRef(
} }
}) })
} }
// If switching to read-only mode, disable Quill instance // Keep Quill's editing state in sync with the isEditable prop
if (editorRef.current && !isEditable) { if (editorRef.current) {
// editorRef.current.disable() editorRef.current.enable(isEditable)
editorRef.current.readOnly = true
// If switching back to editable, enable Quill
if (editorRef.current && isEditable) {
// editorRef.current.enable()
editorRef.current.readOnly = false
}
} }
}, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array }, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
useEffect(() => { useEffect(() => {
if (editorRef.current && isEditable) { if (editorRef.current && isEditable) {
if (editorRef.current.root.innerHTML !== value) { if (editorRef.current.root.innerHTML !== value) {
const html = !initialContentSet.current editorRef.current.root.innerHTML = value || ''
? refreshSignedUrlsInHtml(value || '') // On first load, swap expired image srcs for cached/re-signed ones
: value || '' if (!initialContentSet.current && value) {
patchDescriptionHtml(value).then(html => {
if (editorRef.current && html !== value) {
editorRef.current.root.innerHTML = html
}
})
}
initialContentSet.current = true initialContentSet.current = true
editorRef.current.root.innerHTML = html
} }
} }
}, [value, isEditable]) }, [value, isEditable])
@@ -268,7 +279,7 @@ const RichTextEditor = forwardRef(
boxShadow: boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))', 'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}} }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }} dangerouslySetInnerHTML={{ __html: displayHtml }}
/> />
) )
} }