diff --git a/src/index.html b/src/index.html index 4ecb123..867e68b 100644 --- a/src/index.html +++ b/src/index.html @@ -6491,6 +6491,12 @@ const LINKED_DESIGN_MAX_EVENT_BYTES = 1024 * 1024; const LINKED_DESIGN_MAX_TREE_BYTES = 1024 * 1024; const LINKED_DESIGN_MAX_DEPTH = 3; + // Disk cache: 24-hour TTL (long enough to make return visits + // feel instant; short enough that source edits propagate + // within a day). 4 MB budget, LRU eviction. + const LINKED_DESIGN_DISK_KEY = 'artstr.linkedDesignCache.v1'; + const LINKED_DESIGN_DISK_TTL_MS = 24 * 60 * 60 * 1000; + const LINKED_DESIGN_DISK_MAX_BYTES = 4 * 1024 * 1024; const linkedDesigns = (() => { const cache = new Map(); // key (address or eventId) → { payload, fetchedAt, sourceEvent } const inflight = new Map(); // key → Promise (deduplicates parallel resolves) @@ -6525,38 +6531,72 @@ return entry; } - async function _fetchPayload({ ref, pinnedEventId }) { - const relays = typeof _vaultRelayUnion === 'function' ? _vaultRelayUnion() : splitRelayList(state.community?.relays || ''); - let filter = null; - if (pinnedEventId) { - if (!/^[0-9a-f]{64}$/i.test(pinnedEventId)) return null; - filter = { ids: [pinnedEventId], limit: 1 }; - } else { - const coord = _parseAddressCoord(ref); - if (!coord) return null; - filter = { - kinds: [coord.kind], - authors: [coord.pubkey], - '#d': [coord.dTag], - limit: 5, - }; - } - let events; + // Disk cache (LINKED_DESIGNS_FEATURE.md §7 polish). Mirror + // the in-memory cache to localStorage so a cold reload paints + // without waiting on relays. We store the raw sourceEvent + // only — premium/private detection + vault lookup + decrypt + // re-runs on every read so vault state, sign-in status, and + // unlock changes are always current. LRU-bounded at 4 MB. + function _diskLoad() { try { - events = await queryRelays(relays, filter, { timeoutMs: 4500 }); - } catch (err) { - console.warn('[linked-designs] relay query failed', err); - return null; + const raw = localStorage.getItem(LINKED_DESIGN_DISK_KEY); + if (!raw) return { version: 1, entries: {} }; + const parsed = JSON.parse(raw); + if (parsed?.version !== 1 || !parsed?.entries) return { version: 1, entries: {} }; + return parsed; + } catch { return { version: 1, entries: {} }; } + } + function _diskSave(cache) { + try { localStorage.setItem(LINKED_DESIGN_DISK_KEY, JSON.stringify(cache)); } + catch (err) { + // Likely a quota error — drop the cache to recover. + console.warn('[linked-designs] disk cache save failed (clearing)', err); + try { localStorage.removeItem(LINKED_DESIGN_DISK_KEY); } catch {} } - if (!events || !events.length) { - console.warn('[linked-designs] no events returned for', ref || pinnedEventId); - return null; + } + function _diskRead(key) { + if (!key) return null; + const disk = _diskLoad(); + const entry = disk.entries[key]; + if (!entry) return null; + if (Date.now() - (entry.fetchedAt || 0) > LINKED_DESIGN_DISK_TTL_MS) return null; + return entry.sourceEvent || null; + } + function _diskWrite(key, sourceEvent) { + if (!key || !sourceEvent?.content) return; + const disk = _diskLoad(); + disk.entries[key] = { sourceEvent, fetchedAt: Date.now() }; + _diskEvictIfNeeded(disk); + _diskSave(disk); + } + function _diskDelete(key) { + if (!key) return; + const disk = _diskLoad(); + if (!disk.entries[key]) return; + delete disk.entries[key]; + _diskSave(disk); + } + function _diskEvictIfNeeded(disk) { + const arr = Object.entries(disk.entries); + let totalBytes = 0; + for (const [, e] of arr) totalBytes += (e?.sourceEvent?.content?.length || 0); + if (totalBytes <= LINKED_DESIGN_DISK_MAX_BYTES) return; + // LRU eviction by fetchedAt ascending. + arr.sort((a, b) => (a[1].fetchedAt || 0) - (b[1].fetchedAt || 0)); + while (totalBytes > LINKED_DESIGN_DISK_MAX_BYTES && arr.length) { + const [k, e] = arr.shift(); + delete disk.entries[k]; + totalBytes -= (e?.sourceEvent?.content?.length || 0); } - // Pick the latest by created_at — relays should retain only - // the latest addressable event per address, but defensively - // sort in case more than one slipped through. - events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); - const ev = events[0]; + } + + // Build a resolver entry from a single raw source event. The + // input is whatever queryRelays / disk cache hands us; the + // output is the same { payload, sourceEvent, contentBytes, + // premium?, private? } shape resolveLinked stashes in the + // in-memory cache. Pure given vault state + signing extension + // — same event yields the same entry shape both times. + async function _entryFromEvent(ev) { if (!ev?.content) { console.warn('[linked-designs] source event has empty content', ev?.id); return null; @@ -6668,6 +6708,52 @@ return { payload: parsed, sourceEvent: ev, contentBytes: ev.content.length }; } + // Try disk cache first; on miss query relays + write to disk. + // Either way, the post-fetch entry build runs the same code + // path through _entryFromEvent so vault/private/premium + // detection always reflects current state. + async function _fetchPayload({ ref, pinnedEventId }) { + const key = _cacheKey({ ref, pinnedEventId }); + const cachedEv = _diskRead(key); + if (cachedEv) { + const entry = await _entryFromEvent(cachedEv); + if (entry) return entry; + // Cached event was malformed — drop it and fall through to a fresh fetch. + _diskDelete(key); + } + const relays = typeof _vaultRelayUnion === 'function' ? _vaultRelayUnion() : splitRelayList(state.community?.relays || ''); + let filter = null; + if (pinnedEventId) { + if (!/^[0-9a-f]{64}$/i.test(pinnedEventId)) return null; + filter = { ids: [pinnedEventId], limit: 1 }; + } else { + const coord = _parseAddressCoord(ref); + if (!coord) return null; + filter = { + kinds: [coord.kind], + authors: [coord.pubkey], + '#d': [coord.dTag], + limit: 5, + }; + } + let events; + try { + events = await queryRelays(relays, filter, { timeoutMs: 4500 }); + } catch (err) { + console.warn('[linked-designs] relay query failed', err); + return null; + } + if (!events || !events.length) { + console.warn('[linked-designs] no events returned for', ref || pinnedEventId); + return null; + } + events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + const ev = events[0]; + const entry = await _entryFromEvent(ev); + if (entry) _diskWrite(key, ev); + return entry; + } + // Public: resolve a single linked layer's spec ({ ref, pinnedEventId }). // Returns { payload, sourceEvent, fetchedAt } or null when not // resolvable. Coalesces concurrent calls for the same key into @@ -6747,8 +6833,13 @@ } function clearCache(key) { - if (key) cache.delete(key); - else cache.clear(); + if (key) { + cache.delete(key); + _diskDelete(key); + } else { + cache.clear(); + try { localStorage.removeItem(LINKED_DESIGN_DISK_KEY); } catch {} + } } // Synchronous cache peek for renderers that want to skip the