diff --git a/www/js/version.json b/www/js/version.json index 209882a..7bd19b6 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.104", - "VERSION_NUMBER": "0.7.104", - "BUILD_DATE": "2026-08-17T10:13:32.771Z" + "VERSION": "v0.7.105", + "VERSION_NUMBER": "0.7.105", + "BUILD_DATE": "2026-08-17T10:28:11.271Z" } diff --git a/www/slide-show.html b/www/slide-show.html index d83077f..9ce2c17 100644 --- a/www/slide-show.html +++ b/www/slide-show.html @@ -215,6 +215,33 @@ filter: grayscale(var(--image-grayscale-hover)); } + #btnSlideMore.is-loading { + cursor: progress; + opacity: 0.85; + } + + .braille-spinner { + display: inline-block; + width: 1.1em; + text-align: left; + overflow: hidden; + vertical-align: middle; + white-space: nowrap; + font-family: monospace; + letter-spacing: 0; + } + + .braille-spinner::after { + content: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + display: inline-block; + animation: braille-spin 0.9s steps(10) infinite; + } + + @keyframes braille-spin { + 0% { transform: translateX(0); } + 100% { transform: translateX(-10em); } + } + @media (max-width: 640px) { #divSlideGrid { @@ -382,6 +409,7 @@ const versionInfo = await getVersion(); let notesSubscription = null; let initialLoadSettled = false; let initialLoadTimer = null; + let renderDebounceTimer = null; const noteMap = new Map(); let imagePosts = []; @@ -601,7 +629,18 @@ const versionInfo = await getVersion(); .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); } - function setLoadingState(_isLoading) {} + function setLoadingState(isLoading) { + if (!btnSlideMore) return; + if (isLoading) { + btnSlideMore.classList.add('is-loading'); + btnSlideMore.disabled = true; + btnSlideMore.innerHTML = ' loading'; + } else { + btnSlideMore.classList.remove('is-loading'); + btnSlideMore.disabled = false; + updateMoreButton(); + } + } function getVisibleImagePosts() { const visibleCount = Math.max(0, Math.min(visibleImageCount, imagePosts.length)); @@ -756,6 +795,19 @@ const versionInfo = await getVersion(); updateMoreButton(); } + // Debounce full re-renders triggered by live event updates so a burst of + // incoming events (e.g. the tail of a large subscription) doesn't clear and + // rebuild the grid on every single event, which causes scroll jank. + function scheduleGridRender() { + if (renderDebounceTimer) { + clearTimeout(renderDebounceTimer); + } + renderDebounceTimer = setTimeout(() => { + renderDebounceTimer = null; + renderGrid(); + }, 250); + } + function appendMoreTiles() { if (imagePosts.length === 0) return; divSlideEmpty.style.display = 'none'; @@ -998,14 +1050,20 @@ const versionInfo = await getVersion(); const changed = upsertNoteIfImage(evt); if (changed) { rebuildImagePosts(); - renderGrid(); - setLoadingState(false); - if (initialLoadSettled) showLatestPublishedImageIfEnabled(); + // During the initial load window we only accumulate events; the + // single render happens in settleInitialLoad() using the estimate. + // This avoids re-rendering (and re-laying-out) on every incoming + // event, which caused scroll jank and over-rendering. + if (initialLoadSettled) { + scheduleGridRender(); + showLatestPublishedImageIfEnabled(); + } } } } function startKind1Subscription() { + setLoadingState(true); notesSubscription = subscribe( { kinds: [1], @@ -1031,6 +1089,7 @@ const versionInfo = await getVersion(); return; } + setLoadingState(true); publicPool = new window.NostrTools.SimplePool(); publicSub = publicPool.subscribeMany( PUBLIC_READ_RELAYS, @@ -1040,8 +1099,12 @@ const versionInfo = await getVersion(); const changed = upsertNoteIfImage(evt); if (changed) { rebuildImagePosts(); - renderGrid(); - if (initialLoadSettled) showLatestPublishedImageIfEnabled(); + // Same as handleNdkEvent: only render after the initial load + // settles to avoid scroll jank and over-rendering. + if (initialLoadSettled) { + scheduleGridRender(); + showLatestPublishedImageIfEnabled(); + } } }, oneose: () => { @@ -1086,6 +1149,11 @@ const versionInfo = await getVersion(); clearTimeout(initialLoadTimer); initialLoadTimer = null; } + + if (renderDebounceTimer) { + clearTimeout(renderDebounceTimer); + renderDebounceTimer = null; + } } (async function main() {