// useThumbs — authenticated thumbnail loader for the folder views.
//
// The backend emits `thumb_url` as a ROOT-RELATIVE path ("/api/files/thumb/{id}").
// A plain `
` only works for the native same-origin SPA: an embedded
// webcomponent resolves it against the HOST page's origin (work.example.com → 404)
// and, even with the URL fixed, `
` cannot carry the bearer header a
// proxied host (fishapp PWA) requires. So thumbs are fetched through the same
// auth machinery as every API call (headers + credentials), cached as object
// URLs, and handed to the views; a failed fetch falls back to the file icon.
//
// ⚠⚠ An arrival wakes ONE tile. The object URLs used to live in one reactive
// record that was REPLACED as each thumbnail arrived, so every arrival woke
// everything that had read any thumbnail — the whole folder view, which read
// them all. Field report: a folder of 344 files (~240 thumbnails) re-rendered
// itself ~240 times in a few seconds; a fast Mac lost ~4 s, a slower Windows PC
// froze Edge for ~30 s and ran a Chrome tab out of memory. The cache is now a
// reactive Map read per key, and the views read it through ThumbTile (one
// component per tile), so an arrival re-renders the tile that shows it.
//
// ⚠ Keyed by picture, not by URL. `thumb_url` carries a signature whose expiry
// moves every hour; keyed by URL, the first listing after the hour turned
// fetched every thumbnail of the folder again. The key is the URL's path, its
// `v` (the render's time, 0.50) and the file's `last_modified`: a rotated
// signature is the same picture; an edited file, or the same file drawn again
// because its old picture was stale, is not.
import { shallowReactive, toRaw } from 'vue';
import type { FileNode } from '../types/FileNode';
import { underApiBase } from '../lib/appBase';
interface ThumbApiSlice {
authHeaders: (extra?: Record) => Promise>;
credentialsMode: () => RequestCredentials;
}
/** Cap the object-URL cache; beyond it oldest entries are revoked. Thumbs are
* small (~KBs) so this is generous while still bounding a long session. */
const MAX_CACHED = 500;
/** The cache key for a node's thumbnail — null when it has none. */
function thumbKey(n: FileNode): string | null {
const raw = n.thumb_url;
if (!raw) return null;
const q = raw.indexOf('?');
if (q === -1) return `${raw}||${n.last_modified ?? ''}`;
const v = new URLSearchParams(raw.slice(q + 1)).get('v') ?? '';
return `${raw.slice(0, q)}|${v}|${n.last_modified ?? ''}`;
}
export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
const cache = shallowReactive(new Map());
const order: string[] = [];
/** Signed URLs that failed. Per URL, not per key: 404 (thumb evicted
* server-side) or an auth hiccup is not retried within the session, but a
* freshly signed URL for the same picture may be tried once. */
const failed = new Set();
/** Keys being fetched — one request per picture however many URLs name it. */
const pending = new Set();
// `thumb_url` is relative to the SERVER ROOT, which is the API base (lib/appBase).
const resolveUrl = (raw: string): string => underApiBase(apiBase, raw);
/** Reactive, per node: returns the loaded object URL for the node's thumb,
* kicking off the fetch on first sight. null = not (yet) available → show
* the icon. Only what read THIS node's key is woken when it arrives. */
function src(n: FileNode): string | null {
const raw = n.thumb_url;
const key = thumbKey(n);
if (!raw || !key) return null;
const got = cache.get(key);
if (got) return got;
if (!failed.has(raw) && !pending.has(key)) void load(raw, key);
return null;
}
async function load(raw: string, key: string): Promise {
pending.add(key);
try {
const res = await fetch(resolveUrl(raw), {
headers: await api.authHeaders({ Accept: '*/*' }),
credentials: api.credentialsMode(),
});
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
if (order.length >= MAX_CACHED) {
// ⚠ Dropped without a word to the tile still showing it: woken, the
// tile would fetch it again, and that arrival would drop the next
// oldest — past MAX_CACHED thumbnails on screen, a loop that never
// ends. Its
has long loaded, and a loaded image outlives its
// revoked URL; a tile drawn later asks again, once.
const evict = order.shift();
const store = toRaw(cache);
const old = evict ? store.get(evict) : undefined;
if (evict && old) {
URL.revokeObjectURL(old);
store.delete(evict);
}
}
order.push(key);
cache.set(key, URL.createObjectURL(blob));
} catch {
failed.add(raw);
} finally {
pending.delete(key);
}
}
return { src };
}