Refactor Attchemnt Upload and fix bug with signing
This commit is contained in:
@@ -138,6 +138,13 @@ class ApiClient {
|
||||
} catch (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 {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
@@ -252,7 +259,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
return this.request(endpoint, {
|
||||
options: { ...options },
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: data ? data : undefined,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
@@ -737,6 +741,22 @@ const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) =>
|
||||
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 => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'GET',
|
||||
@@ -958,7 +978,9 @@ const TrackFilterUsage = id => {
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteChoreAttachment,
|
||||
DeleteDraftAttachment,
|
||||
GetChoreAttachments,
|
||||
SignAssetURL,
|
||||
UploadChoreAttachment,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
@@ -1060,6 +1082,5 @@ export {
|
||||
UpdateThingState,
|
||||
UpdateTimeSession,
|
||||
UpdateUserDetails,
|
||||
VerifyMFA
|
||||
VerifyMFA,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,111 +13,74 @@ const resolvePhotoURL = url => {
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
|
||||
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
|
||||
const isCloudSignedUrl = url => {
|
||||
if (!url) return false
|
||||
// Returns the expiry of a signed asset URL in epoch ms, or null when the URL
|
||||
// carries no expiry (public assets, plain paths, OIDC picture URLs).
|
||||
// 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 {
|
||||
return (
|
||||
url.includes('X-Amz-Signature') ||
|
||||
url.includes('X-Amz-Expires') ||
|
||||
url.includes('X-Goog-Expires') ||
|
||||
url.includes('expires')
|
||||
)
|
||||
} catch(e) {
|
||||
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
|
||||
const query = new URL(url, 'http://relative.local').searchParams
|
||||
if (query.has('expires')) {
|
||||
const expires = parseInt(query.get('expires'), 10)
|
||||
return Number.isFinite(expires) ? expires * 1000 : null
|
||||
}
|
||||
if (query.has('X-Amz-Expires') && query.has('X-Amz-Date')) {
|
||||
const iso = query
|
||||
.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',
|
||||
)
|
||||
const start = Date.parse(iso)
|
||||
const validFor = parseInt(query.get('X-Amz-Expires'), 10)
|
||||
if (Number.isFinite(start) && Number.isFinite(validFor)) {
|
||||
return start + validFor * 1000
|
||||
}
|
||||
} 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
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Scan an HTML string for <img> 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('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
|
||||
// One minute of clock skew so we refresh before the server starts rejecting.
|
||||
const isSignedUrlExpired = url => {
|
||||
const expiry = getSignedUrlExpiry(url)
|
||||
return expiry != null && Date.now() > expiry - 60_000
|
||||
}
|
||||
|
||||
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
301
src/utils/ImageCache.js
Normal 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 ("&"), 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('&', '&') : 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,
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UpdateChoreHistory,
|
||||
UpdateDueDate,
|
||||
} from './Fetcher'
|
||||
import { syncOfflineImages } from './ImageCache'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
@@ -58,6 +59,13 @@ class SyncEngine {
|
||||
// Sync succeeded — server is reachable (only sync success restores online status)
|
||||
networkManager.setServerReachable()
|
||||
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
|
||||
} catch (err) {
|
||||
await commandQueue.resetSyncing()
|
||||
@@ -242,6 +250,13 @@ class SyncEngine {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
if (!chores || chores.length === 0) return
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user