Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@
-->
<FirstStart v-else-if="isFirstStart" />

<!--
The configuration could not be loaded (e.g. offline first start).
It is retried in the background, so show a loading indicator
instead of nothing at all.
-->
<XLoadingIcon class="fill-block" v-else-if="isConfigUnknown" />

<!-- Render the actual app when configuration has been loaded -->
<template v-else-if="!isConfigUnknown">
<template v-else>
<NcAppNavigation v-if="showNavigation">
<template #list>
<NcAppNavigationItem
Expand Down
85 changes: 84 additions & 1 deletion src/components/Timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ import { defineComponent } from 'vue';
import type { Route } from 'vue-router';

import axios from '@nextcloud/axios';
import { showError } from '@nextcloud/dialogs';
import { showError, showInfo } from '@nextcloud/dialogs';

import { getLayout } from '@services/layout';

Expand All @@ -120,6 +120,8 @@ import SelectionManager from '@components/SelectionManager.vue';
import Viewer from '@components/viewer/Viewer.vue';
import SwipeRefresh from './SwipeRefresh.vue';

import { fetchImage } from '@components/frame/XImgCache';

import EmptyContent from '@components/top-matter/EmptyContent.vue';
import TopMatter from '@components/top-matter/TopMatter.vue';
import DynamicTopMatter from '@components/top-matter/DynamicTopMatter.vue';
Expand Down Expand Up @@ -173,6 +175,8 @@ export default defineComponent({
heads: new Map<number, IHeadRow>(),
/** 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],
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);
Expand All @@ -772,17 +781,91 @@ 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
if (!cache) this.updateLoading(-1);
}
},

/**
* 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<IPhoto[]>(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
Expand Down
52 changes: 51 additions & 1 deletion src/components/frame/XImgCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> | 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<string> {
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<boolean> {
workerPingTime = Date.now();
return (workerPing ??= new Promise<boolean>((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<string> {
// 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();
Expand All @@ -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))) {
Expand Down
15 changes: 12 additions & 3 deletions src/components/frame/XImgWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const pendingUrls = new Map<string, BlobCallback[]>();
// 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<void> = (async function openCache() {
try {
imageCache = await self.caches?.open(cacheName);
} catch {
Expand Down Expand Up @@ -208,7 +211,8 @@ async function flushPreviewQueue() {

/** Accepts a URL and returns a promise with a blob */
async function fetchImage(url: string): Promise<Blob> {
// 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();

Expand Down Expand Up @@ -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 });
24 changes: 19 additions & 5 deletions src/components/top-matter/OnThisDay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down
2 changes: 2 additions & 0 deletions src/mixins/UserConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
35 changes: 29 additions & 6 deletions src/service-worker.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<PrecacheEntry>;

// 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({
Expand Down Expand Up @@ -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',
}),
);
Expand Down
Loading