diff --git a/src/index.html b/src/index.html
index a716fc0..517380f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -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)';