book(D1.7): upstream-update chips on tiles + cover

After the resolver paints from the warm cache, a background probe
queries relays for the latest event matching each naddr ref and
compares against the cached created_at. Refs with a newer upstream
version stage in _bookUpdates; tiles render an "↑ Update" chip,
and the cover-view hint surfaces a matching "Apply update" button.
Click applies the event (decrypting envelopes when private), writes
through to the disk cache, and clears the flag.

Implementation:

- checkBookSidecarUpdates(book) batches every live naddr ref
  (cover / page / master / chapter; pinned eventId refs skipped,
  pinned chapter refs also skipped) by kind:pubkey, runs one
  queryRelays filter per group, and stages newer events in a
  session-only Map.
- applyBookUpdate(ref) reuses the resolver's apply helpers +
  envelope decryption so the application path is identical to a
  fresh fetch.
- Probe fires from resolveBookSidecars(non-force) on a 600 ms
  delay so the warm cache paints first; force-refresh from the
  sidebar skips it (the relays were already hit).
- Tile chip styling lives next to .bookPageTileNum; cover hint
  injects an inline button when hasBookUpdate(book.cover.ref).
- Every successful Publish (page / master / cover / chapter) calls
  clearBookUpdate(ref) so a re-publish doesn't leave a stale
  chip pointing at the version the user just superseded.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-03 13:17:06 -04:00
1 parent 77514cf408
commit 5b19c25e2b
1 file changed
+184 -1
+184 -1
View File
@@ -1676,6 +1676,15 @@
.bookPageTile.reflowSegment {
background: linear-gradient(135deg, #f8fafc 25%, transparent 25%, transparent 50%, #f8fafc 50%, #f8fafc 75%, transparent 75%) 0 0/8px 8px;
}
.bookPageTileUpdateChip {
position: absolute; top: 4px; left: 4px;
padding: 2px 8px; font-size: .72rem;
background: #fef3c7; color: #92400e;
border: 1px solid #fbbf24; border-radius: 999px;
cursor: pointer; z-index: 5;
}
.bookPageTileUpdateChip:hover { background: #fde68a; }
.bookPageTileUpdateChip:disabled { opacity: .6; cursor: progress; }
.bookPageTileNum {
position: absolute; top: 6px; left: 8px;
font-size: .72rem; color: var(--muted); font-weight: 600;
@@ -9142,6 +9151,24 @@
});
actions.appendChild(del);
tile.appendChild(actions);
// Upstream-update chip (D1.7). Shown when the resolver's
// background probe found a newer version of this entry's
// ref upstream. Click applies it.
const tileRef = p.type === 'reflow' ? p.textRef : p.ref;
if (tileRef && hasBookUpdate(tileRef)) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'bookPageTileUpdateChip';
chip.textContent = '↑ Update';
chip.title = 'A newer version of this event is on Nostr — click to apply.';
chip.addEventListener('click', async (e) => {
e.stopPropagation();
chip.disabled = true;
chip.textContent = '…';
await applyBookUpdate(tileRef);
});
tile.appendChild(chip);
}
const label = document.createElement('div');
label.className = 'bookPageTileLabel';
if (p.type === 'reflow') {
@@ -10053,6 +10080,7 @@
pushHistory('book: page publish');
entry.ref = { kind: 30078, pubkey: signed.pubkey, dTag, relays, encrypted: isPrivate || undefined };
_bookCacheWrite(entry.ref, signed);
clearBookUpdate(entry.ref);
setMsg(`${isPrivate ? 'Privately published' : 'Published'} ${signed.id.slice(0, 8)}…`, 'ok');
render();
} catch (err) {
@@ -10111,6 +10139,7 @@
pushHistory('book: master publish');
m.ref = { kind: 30078, pubkey: signed.pubkey, dTag, relays, encrypted: isPrivate || undefined };
_bookCacheWrite(m.ref, signed);
clearBookUpdate(m.ref);
setMsg(`${isPrivate ? 'Privately published' : 'Published'} ${signed.id.slice(0, 8)}…`, 'ok');
render();
} catch (err) {
@@ -10120,6 +10149,133 @@
if (btn) btn.disabled = false;
}
}
// Upstream-update tracking. After the initial cache-warm resolve,
// we probe relays in one batched round-trip for the latest event
// matching each naddr ref. If a newer created_at comes back, the
// ref is staged in `_bookUpdates` and tiles render an "↑" chip
// that applies the update on click. Pinned (eventId) refs can't
// have newer versions, so we skip them.
const _bookUpdates = new Map(); // refKey → { ev, oldCreatedAt }
function _bookUpdateKey(ref) {
return _bookCacheRefKey(ref);
}
function hasBookUpdate(ref) {
const k = _bookUpdateKey(ref);
return k ? _bookUpdates.has(k) : false;
}
function clearBookUpdate(ref) {
const k = _bookUpdateKey(ref);
if (k) _bookUpdates.delete(k);
}
async function checkBookSidecarUpdates(book = state.book) {
if (!book) return { newer: 0 };
// Gather every live naddr ref + its cached created_at.
const candidates = [];
const seenKeys = new Set();
const collect = (ref, ctx) => {
if (!ref || !ref.dTag || !ref.pubkey || ref.eventId) return;
const key = _bookUpdateKey(ref);
if (!key || seenKeys.has(key)) return;
seenKeys.add(key);
const cached = _bookCacheRead(ref);
const oldCreatedAt = cached?.created_at || 0;
candidates.push({ key, ref, oldCreatedAt, ctx });
};
if (book.cover?.ref) collect(book.cover.ref, { kind: 'cover' });
for (const m of (book.masters || [])) if (m.ref) collect(m.ref, { kind: 'master', masterId: m.id });
for (const p of (book.pages || [])) {
if (p.type === 'design' && p.ref) collect(p.ref, { kind: 'page', entryId: p.id });
if (p.type === 'reflow' && p.textRef && !p.textPinnedEventId) collect(p.textRef, { kind: 'chapter', entryId: p.id });
}
if (!candidates.length) return { newer: 0 };
// Batch by kind:pubkey so each group needs one filter.
const byAuthorKind = new Map();
for (const c of candidates) {
const k = `${c.ref.kind || 30078}:${c.ref.pubkey}`;
let group = byAuthorKind.get(k);
if (!group) { group = []; byAuthorKind.set(k, group); }
group.push(c);
}
const filters = [];
for (const [k, group] of byAuthorKind) {
const [kindStr, pubkey] = k.split(':');
const dTags = [...new Set(group.map(g => g.ref.dTag))];
filters.push({ kinds: [Number(kindStr)], authors: [pubkey], '#d': dTags, limit: dTags.length * 2 });
}
const relays = splitRelayList(state.community?.relays || '');
let events;
try {
events = await queryRelays(relays, filters, { timeoutMs: 4000 });
} catch (err) {
console.warn('[book] upstream-update probe failed', err);
return { newer: 0 };
}
// Bucket events by addressable coord, keeping the latest.
const byAddr = new Map();
for (const ev of (events || [])) {
const dTag = (ev.tags || []).find(t => t[0] === 'd' && t[1] != null)?.[1] || '';
const addr = `${ev.kind}:${ev.pubkey}:${dTag}`;
const prev = byAddr.get(addr);
if (!prev || (ev.created_at || 0) > (prev.created_at || 0)) byAddr.set(addr, ev);
}
let newer = 0;
for (const c of candidates) {
const addr = `${c.ref.kind || 30078}:${c.ref.pubkey}:${c.ref.dTag}`;
const ev = byAddr.get(addr);
if (!ev) continue;
if ((ev.created_at || 0) > (c.oldCreatedAt || 0)) {
_bookUpdates.set(c.key, { ev, oldCreatedAt: c.oldCreatedAt, ctx: c.ctx });
newer++;
}
}
if (newer) render();
return { newer };
}
// Apply a staged upstream update to its target surface (page /
// master / cover / chapter). Decrypts when needed, writes the
// new event to the disk cache, and clears the staged flag.
async function applyBookUpdate(ref) {
const key = _bookUpdateKey(ref);
const update = key ? _bookUpdates.get(key) : null;
if (!update) return false;
const ev = update.ev;
const ctx = update.ctx;
try {
let payload = null;
try {
const decrypted = await _decryptBookSidecarEvent(ev);
if (decrypted) payload = decrypted;
} catch {}
if (!payload) {
if (ctx.kind === 'chapter' && ev.kind === 30023) {
_applyResolvedBookChapter(ctx.entryId, ev.content || '');
_bookCacheWrite(ref, ev);
_bookUpdates.delete(key);
render();
return true;
}
try { payload = JSON.parse(ev.content || ''); } catch { payload = null; }
}
if (!payload) return false;
pushHistory('book: apply upstream update');
if (ctx.kind === 'page') _applyResolvedBookPage(ctx.entryId, payload);
else if (ctx.kind === 'master') _applyResolvedBookMaster(ctx.masterId, payload);
else if (ctx.kind === 'cover') _applyResolvedBookCover(payload);
else if (ctx.kind === 'chapter') {
const md = payload?.chapter?.markdown ?? payload?.content ?? '';
_applyResolvedBookChapter(ctx.entryId, md);
}
_bookCacheWrite(ref, ev);
_bookUpdates.delete(key);
render();
return true;
} catch (err) {
console.warn('book: applyBookUpdate failed', err);
return false;
}
}
// Publish a book sidecar (page / master / chapter) as an
// encrypted kind-30078 event under an explicit, stable d-tag —
// unlike publishEncryptedDesign (which key-wraps to the project's
@@ -10362,6 +10518,16 @@
})().finally(() => { _bookResolveInFlight = null; });
await _bookResolveInFlight;
render();
// Kick off the upstream-update probe after the initial paint
// so the user sees the warm cache immediately, then a chip
// appears on tiles whose ref has a newer version upstream.
// Force-refreshes (which already fetched the latest) don't
// re-probe — they wrote the cache through.
if (!opts.force) {
setTimeout(() => {
checkBookSidecarUpdates(book).catch(err => console.warn('book update probe failed', err));
}, 600);
}
return summary;
}
@@ -10527,6 +10693,7 @@
pushHistory('book: cover publish');
book.cover = { ref: { kind: 30078, pubkey: signed.pubkey, dTag, relays, encrypted: isPrivate || undefined } };
_bookCacheWrite(book.cover.ref, signed);
clearBookUpdate(book.cover.ref);
setMsg(`${isPrivate ? 'Privately published' : 'Published'} ${signed.id.slice(0, 8)}…`, 'ok');
render();
} catch (err) {
@@ -10652,6 +10819,7 @@
entry.textRef = { kind: 30023, pubkey: signed.pubkey, dTag, relays };
}
_bookCacheWrite(entry.textRef, signed);
clearBookUpdate(entry.textRef);
entry.textPinnedEventId = null; // own publishes default to live
setMsg(`${isPrivate ? 'Privately published' : 'Published'} as ${signed.id.slice(0, 8)}… (${md.length.toLocaleString()} chars).`, 'ok');
syncBookChapterEditor();
@@ -10946,10 +11114,25 @@
if (refHint) {
const ref = book.cover?.ref;
const coverLayerCount = (state.layers || []).filter(l => /^book-cover-|^book-spine$/.test(String(l.target || ''))).length;
refHint.innerHTML = '';
if (ref) {
const enc = ref.encrypted ? ' 🔒' : '';
refHint.textContent = `Published${enc} as ${ref.dTag} (${coverLayerCount} layer${coverLayerCount === 1 ? '' : 's'}).`;
const base = document.createElement('span');
base.textContent = `Published${enc} as ${ref.dTag} (${coverLayerCount} layer${coverLayerCount === 1 ? '' : 's'}).`;
refHint.appendChild(base);
refHint.style.color = '#15803d';
if (hasBookUpdate(ref)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '↑ Apply update';
btn.style.cssText = 'margin-left:8px;padding:2px 8px;font-size:.72rem;background:#fef3c7;color:#92400e;border:1px solid #fbbf24;border-radius:999px;cursor:pointer';
btn.title = 'A newer version of this cover is on Nostr — click to apply.';
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '…';
await applyBookUpdate(ref);
});
refHint.appendChild(btn);
}
} else if (coverLayerCount) {
refHint.textContent = `${coverLayerCount} layer${coverLayerCount === 1 ? '' : 's'} ready — Edit cover then Publish to mint a casewrap-page event.`;
refHint.style.color = 'var(--muted)';