diff --git a/src/App.vue b/src/App.vue
index f1ef83ccb..7705e4ccf 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -22,8 +22,15 @@
-->
+
+
+
-
+
(),
/** Current list (days response) was loaded from cache */
daysIsCache: false,
+ /** User was already notified of being offline */
+ offlineNotified: false,
/** Size of outer container [w, h] */
containerSize: [0, 0] as [number, number],
@@ -229,6 +233,7 @@ export default defineComponent({
},
created() {
+ window.addEventListener('online', this.onWindowOnline);
utils.bus.on('memories:user-config-changed', this.softRefresh);
utils.bus.on('files:file:created', this.softRefresh);
utils.bus.on('memories:window:resize', this.handleResizeWithDelay);
@@ -239,6 +244,7 @@ export default defineComponent({
},
beforeDestroy() {
+ window.removeEventListener('online', this.onWindowOnline);
utils.bus.off('memories:user-config-changed', this.softRefresh);
utils.bus.off('files:file:created', this.softRefresh);
utils.bus.off('memories:window:resize', this.handleResizeWithDelay);
@@ -748,6 +754,9 @@ export default defineComponent({
await this.processDays(cache, true);
this.updateLoading(-1);
+
+ // Tell the user immediately if we know we are offline
+ if (!navigator.onLine) this.notifyOffline();
}
} catch {
console.warn(`Failed to process days cache: ${cacheUrl}`);
@@ -772,10 +781,16 @@ export default defineComponent({
// Make sure we're still on the same page
if (this.state !== startState) return;
await this.processDays(data, false);
+
+ // Warm the offline caches in the background (native only)
+ this.prefetchOffline(data);
} catch (e) {
if (!utils.isNetworkError(e)) {
showError(e?.response?.data?.message ?? e.message);
console.error(e);
+ } else if (cache) {
+ // We are offline but the cached content was shown
+ this.notifyOffline();
}
} finally {
// If cache is set here, loading was already decremented
@@ -783,6 +798,74 @@ export default defineComponent({
}
},
+ /**
+ * Tell the user once that cached content is being shown offline.
+ */
+ notifyOffline() {
+ if (this.offlineNotified) return;
+ this.offlineNotified = true;
+ showInfo(this.t('memories', 'You are offline; showing cached content'));
+ },
+
+ /**
+ * Refresh the timeline when connectivity returns.
+ */
+ onWindowOnline() {
+ this.offlineNotified = false;
+ this.softRefresh();
+ },
+
+ /**
+ * Warm the offline caches for the most recent days in the background,
+ * so that recently synced photos can be browsed offline (#854).
+ *
+ * Runs only on the main timeline of the native app, at most once per
+ * hour. Photos are fetched through the image worker, which is cache
+ * first: anything already cached costs nothing.
+ */
+ async prefetchOffline(days: IDay[]) {
+ if (!nativex.has() || !this.routeIsBase || !navigator.onLine) return;
+
+ // Throttle to once per hour
+ const lsKey = 'memories_offline_prefetch_time';
+ const last = Number(localStorage.getItem(lsKey) ?? 0);
+ if (Date.now() - last < 3600 * 1000) return;
+ localStorage.setItem(lsKey, Date.now().toString());
+
+ // Choose the most recent days up to a total budget of photos
+ const chosen: IDay[] = [];
+ let count = 0;
+ for (const day of days) {
+ if (day.count <= 0) continue;
+ chosen.push(day);
+ if ((count += day.count) >= 500) break;
+ }
+
+ const state = this.state;
+ for (const day of chosen) {
+ // Stop if the user navigated away or went offline
+ if (this.state !== state || !navigator.onLine) return;
+
+ try {
+ // Fetch and cache the day detail (same URL as fetchDay)
+ const url = this.getDayUrl([day.dayid]);
+ const res = await axios.get(url);
+ if (res.status !== 200) continue;
+ utils.cacheData(url, res.data);
+
+ // Warm the preview cache; batched into multipreview by the worker
+ await Promise.allSettled(
+ res.data
+ .filter((photo) => !utils.isLocalPhoto(photo))
+ .map((photo) => fetchImage(utils.getPreviewUrl({ photo, msize: 256 }))),
+ );
+ } catch {
+ // Offline or server error; try again on the next run
+ return;
+ }
+ }
+ },
+
/**
* Process the data for days call including folders
* @param data Days data
diff --git a/src/components/frame/XImgCache.ts b/src/components/frame/XImgCache.ts
index 221e71e18..a37d0f697 100644
--- a/src/components/frame/XImgCache.ts
+++ b/src/components/frame/XImgCache.ts
@@ -58,6 +58,56 @@ export async function sticky(url: string, delta: number) {
}
}
+// Worker health: null = unknown yet, false = did not answer the ping
+let workerAlive: boolean | null = null;
+let workerPing: Promise | null = null;
+let workerPingTime = 0;
+
+/**
+ * Fetch an image on the main thread (cache first).
+ *
+ * The worker cannot start while offline on Android WebView: worker
+ * script requests are not routed through the service worker, so the
+ * fetch of the worker script itself hangs without connectivity.
+ */
+async function fetchImageDirect(url: string): Promise {
+ const cached = await window.caches?.open('memories-images').then((c) => c.match(url));
+ const blob = cached ? await cached.blob() : await (await fetch(url)).blob();
+ return URL.createObjectURL(blob);
+}
+
+/** Check once whether the worker actually runs (2s deadline) */
+function pingWorker(): Promise {
+ workerPingTime = Date.now();
+ return (workerPing ??= new Promise((resolve) => {
+ const timer = window.setTimeout(() => resolve(false), 2000);
+ worker.ping().then(
+ () => {
+ window.clearTimeout(timer);
+ resolve(true);
+ },
+ () => {
+ window.clearTimeout(timer);
+ resolve(false);
+ },
+ );
+ }).then((alive) => (workerAlive = alive)));
+}
+
+/** Fetch through the worker, or on the main thread if it does not run */
+async function fetchImageSafe(url: string): Promise {
+ // Probe again periodically: the worker may become able to start
+ // after connectivity returns
+ if (workerAlive === false && Date.now() - workerPingTime > 30000) {
+ workerAlive = null;
+ workerPing = null;
+ }
+
+ if (workerAlive === null) await pingWorker();
+ if (!workerAlive) return await fetchImageDirect(url);
+ return await worker.fetchImageSrc(url);
+}
+
export async function fetchImage(url: string) {
// Start worker
startWorker();
@@ -67,7 +117,7 @@ export async function fetchImage(url: string) {
if (entry) return entry[1];
// Fetch image
- const blobUrl = await worker.fetchImageSrc(url);
+ const blobUrl = await fetchImageSafe(url);
// Check memcache entry again and revoke if it was added in the meantime
if ((entry = BLOB_CACHE.get(url))) {
diff --git a/src/components/frame/XImgWorker.ts b/src/components/frame/XImgWorker.ts
index cddd775a3..96636096b 100644
--- a/src/components/frame/XImgWorker.ts
+++ b/src/components/frame/XImgWorker.ts
@@ -24,7 +24,10 @@ const pendingUrls = new Map();
// Cache for preview images
const cacheName = 'memories-images';
let imageCache: Cache | undefined;
-(async function openCache() {
+// The first image requests arrive before the cache is open, so keep the
+// promise around and await it before matching; otherwise those requests
+// silently skip the cache and always hit the network (offline: forever)
+const imageCachePromise: Promise = (async function openCache() {
try {
imageCache = await self.caches?.open(cacheName);
} catch {
@@ -208,7 +211,8 @@ async function flushPreviewQueue() {
/** Accepts a URL and returns a promise with a blob */
async function fetchImage(url: string): Promise {
- // Check if in cache
+ // Check if in cache (wait for the cache to open first)
+ await imageCachePromise;
const cache = await imageCache?.match(url);
if (cache) return await cache.blob();
@@ -317,5 +321,10 @@ async function fetchImageSrc(url: string) {
return URL.createObjectURL(await fetchImage(url));
}
+/** Health check: confirms the worker script has loaded and runs */
+async function ping() {
+ return true;
+}
+
// Exports to main thread
-export default exportWorker({ fetchImageSrc, configure });
+export default exportWorker({ fetchImageSrc, configure, ping });
diff --git a/src/components/top-matter/OnThisDay.vue b/src/components/top-matter/OnThisDay.vue
index f1973acb3..bd2b965b8 100644
--- a/src/components/top-matter/OnThisDay.vue
+++ b/src/components/top-matter/OnThisDay.vue
@@ -114,12 +114,26 @@ export default defineComponent({
if (cache) this.process(cache);
// Network request
- const photos = await dav.getOnThisDayRaw();
- utils.cacheData(cacheUrl, photos);
+ const revalidate = async () => {
+ const photos = await dav.getOnThisDayRaw();
+ utils.cacheData(cacheUrl, photos);
- // Check if exactly same as cache
- if (cache?.length === photos.length && cache.every((p, i) => p.fileid === photos[i].fileid)) return;
- this.process(photos);
+ // Check if exactly same as cache
+ if (cache?.length === photos.length && cache.every((p, i) => p.fileid === photos[i].fileid)) return;
+ this.process(photos);
+ };
+
+ // If we already rendered from cache, revalidate in the background:
+ // this promise blocks the first render of the whole timeline, which
+ // must not wait on the network (an offline or slow connection would
+ // freeze the app for the duration of the request timeout)
+ if (cache) {
+ revalidate().catch((e) => {
+ if (!utils.isNetworkError(e)) console.error(e);
+ });
+ } else {
+ await revalidate();
+ }
},
async process(photos: IPhoto[]) {
diff --git a/src/mixins/UserConfig.ts b/src/mixins/UserConfig.ts
index 11333dc65..1da780a14 100644
--- a/src/mixins/UserConfig.ts
+++ b/src/mixins/UserConfig.ts
@@ -21,11 +21,13 @@ export default defineComponent({
created() {
utils.bus.on(eventName, this.updateLocalSetting);
+ utils.bus.on('memories:static-config-loaded', this.refreshFromConfig);
this.refreshFromConfig();
},
beforeDestroy() {
utils.bus.off(eventName, this.updateLocalSetting);
+ utils.bus.off('memories:static-config-loaded', this.refreshFromConfig);
},
methods: {
diff --git a/src/service-worker.ts b/src/service-worker.ts
index c35a67391..13056465e 100644
--- a/src/service-worker.ts
+++ b/src/service-worker.ts
@@ -1,5 +1,5 @@
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
-import { NetworkFirst, CacheFirst } from 'workbox-strategies';
+import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import { registerRoute } from 'workbox-routing';
import { ExpirationPlugin } from 'workbox-expiration';
@@ -10,12 +10,30 @@ type PrecacheEntry = Exclude<(typeof self.__WB_MANIFEST)[number], string>;
// Paths are updated in PHP. See OtherController.php
const manifest = self.__WB_MANIFEST as Array;
-// Only include JS files
-const filteredManifest = manifest.filter((entry) => /\.js(\?.*)?$/.test(entry.url));
+// Only include JS files.
+// The webpack output names carry the content hash as a ?v= parameter;
+// move it to the workbox revision so that the cache key is the bare
+// URL. Pages request scripts with a *different* ?v= (the Nextcloud
+// version), so without this normalization no page request would ever
+// match the precache and updates would never reach the browser.
+const filteredManifest = manifest
+ .filter((entry) => /\.js(\?.*)?$/.test(entry.url))
+ .map((entry) => {
+ const [url, query] = entry.url.split('?');
+ return { url, revision: entry.revision ?? new URLSearchParams(query).get('v') };
+ });
-precacheAndRoute(filteredManifest);
+precacheAndRoute(filteredManifest, {
+ // Match page requests regardless of the ?v= version parameter
+ ignoreURLParametersMatching: [/^v$/, /^utm_/],
+});
cleanupOutdatedCaches();
+// Activate updated workers immediately instead of waiting for all
+// pages to close: precached URLs are revisioned, so an already-open
+// page keeps working against the refreshed cache.
+self.addEventListener('install', () => self.skipWaiting());
+
registerRoute(
/\/apps\/memories\/api\/video\/livephoto\/.*/,
new CacheFirst({
@@ -59,10 +77,15 @@ const netonly = [
/\/csrftoken/i, // CSRF token (https://github.com/pulsejet/memories/issues/835)
];
-// Use NetworkFirst for HTML pages for initial state and CSRF token
+// Serve HTML pages from the cache immediately and revalidate in the
+// background, so that startup does not depend on the network at all.
+// The embedded initial state and request token may be one navigation
+// stale: the timeline refreshes itself over the API anyway, and the
+// request token stays valid for the lifetime of the session (the
+// network-only /csrftoken route handles renewal).
registerRoute(
({ url }) => url.origin === self.location.origin && !netonly.some((regex) => regex.test(url.pathname)),
- new NetworkFirst({
+ new StaleWhileRevalidate({
cacheName: 'memories-pages',
}),
);
diff --git a/src/services/static-config.ts b/src/services/static-config.ts
index 822e3f413..d7c90a8c5 100644
--- a/src/services/static-config.ts
+++ b/src/services/static-config.ts
@@ -22,7 +22,11 @@ class StaticConfig {
private async init() {
try {
- this.config = (await axios.get(API.CONFIG_GET())).data;
+ // Bound the request: much of the app waits on this config, and
+ // without a timeout an offline boot (e.g. behind a VPN that
+ // blackholes packets) blocks everything until the socket gives
+ // up. On timeout we fall back to the persisted configuration.
+ this.config = (await axios.get(API.CONFIG_GET(), { timeout: 5000 })).data;
} catch (e) {
if (!utils.isNetworkError(e)) {
showError('Failed to load configuration');
@@ -30,6 +34,11 @@ class StaticConfig {
// Offline or fail, continue with default configuration
this.config = this.getDefault();
+
+ // Retry in the background: the request may fail transiently
+ // (e.g. offline start, or flaky connectivity right after login).
+ // Without the remote configuration a first start is unusable.
+ this.scheduleRetry();
}
// Check if version changed
@@ -74,6 +83,32 @@ class StaticConfig {
}
}
+ /**
+ * Retry fetching the remote configuration with backoff after the
+ * initial attempt failed, then propagate the values to components.
+ */
+ private scheduleRetry(attempt: number = 0) {
+ if (attempt >= 8) return;
+
+ setTimeout(
+ async () => {
+ try {
+ const config = (await axios.get(API.CONFIG_GET(), { timeout: 5000 })).data;
+ for (const k in config) {
+ const key = k as keyof IConfig;
+ this.setLs(key, config[key]);
+ }
+
+ // Tell UserConfig components to pick up the new values
+ utils.bus.emit('memories:static-config-loaded', null);
+ } catch {
+ this.scheduleRetry(attempt + 1);
+ }
+ },
+ Math.min(30000, 2000 * 2 ** attempt),
+ );
+ }
+
public async getAll() {
await this.waitForInit();
return this.config!;
diff --git a/src/services/utils/cache.ts b/src/services/utils/cache.ts
index 6c73e3c59..fd291cdd7 100644
--- a/src/services/utils/cache.ts
+++ b/src/services/utils/cache.ts
@@ -3,7 +3,13 @@ import { uid } from './helpers';
/** Cache keys */
async function getCacheName() {
- const ver = await config.get('version');
+ // Do not block on the remote configuration for the cache name: fall
+ // back to the persisted version if it takes too long (e.g. offline
+ // start), so that cached data can render without waiting on the network
+ const ver = await Promise.race([
+ config.get('version'),
+ new Promise((res) => setTimeout(() => res(config.getSync('version')), 1000)),
+ ]);
return `memories-data-${ver}-${uid}`;
}
diff --git a/src/services/utils/event-bus.ts b/src/services/utils/event-bus.ts
index 7c26adfa8..aa590c02e 100644
--- a/src/services/utils/event-bus.ts
+++ b/src/services/utils/event-bus.ts
@@ -27,6 +27,9 @@ export type BusEvent = {
value: IConfig[keyof IConfig];
} | null;
+ /** The remote static configuration was loaded after a failed first attempt */
+ 'memories:static-config-loaded': null;
+
/**
* Remove these photos from the timeline.
* Each photo object is required to have the `d` (day) property.
diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts
index 4a51375af..ab7733eb7 100644
--- a/src/services/utils/helpers.ts
+++ b/src/services/utils/helpers.ts
@@ -204,7 +204,9 @@ export function removeExtension(filename: string) {
* Check if the provided Axios Error is a network error.
*/
export function isNetworkError(error: any) {
- return error?.code === 'ERR_NETWORK';
+ // ECONNABORTED is a client-side timeout, which almost always
+ // means the network is unusable (e.g. offline behind a VPN)
+ return error?.code === 'ERR_NETWORK' || error?.code === 'ECONNABORTED';
}
/**