From e5fa16a88d9ee518095c1821e1c9c1cca0ecb4ff Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sat, 9 May 2026 13:31:25 -0300 Subject: [PATCH] Count views on stream --- public/assets/app.js | 116 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/public/assets/app.js b/public/assets/app.js index eaac60e..83888dc 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1,5 +1,9 @@ const PAGE_SIZE = 12; const MAX_FILE_BYTES = 5 * 1024 * 1024; +const VIEW_DWELL_MS = 1000; +const VIEW_THRESHOLD = 0.9; +const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000; +const VIEW_DEDUPE_PREFIX = 'meme-viewed:'; const SAFE_TYPES = new Set(['image/png', 'image/jpeg']); const grid = document.querySelector('#meme-grid'); @@ -21,6 +25,8 @@ let currentPage = 0; let totalPages = 1; let isLoading = false; let latestValidationRun = 0; +const viewedThisSession = new Set(); +const pendingViewTimers = new Map(); document.querySelector('#open-upload').addEventListener('click', () => { formStatus.textContent = ''; @@ -83,7 +89,7 @@ grid.addEventListener('click', async (event) => { lightboxDownload.href = viewButton.dataset.downloadUrl; lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`; lightbox.showModal(); - await recordView(viewButton.dataset.viewId); + await recordViewOnce(viewButton.dataset.viewId); }); const observer = new IntersectionObserver((entries) => { @@ -92,6 +98,10 @@ const observer = new IntersectionObserver((entries) => { } }, { rootMargin: '420px 0px' }); +const viewObserver = 'IntersectionObserver' in window + ? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] }) + : null; + connectLiveCounters(); updateScrollIndicator(); window.addEventListener('scroll', updateScrollIndicator, { passive: true }); @@ -132,6 +142,7 @@ async function loadMemes(page, options = {}) { function renderMemes(memes, options = {}) { if (!options.append && memes.length === 0) { + unobserveViewedImages(); grid.innerHTML = `
NO_MEMES_IN_STREAM
`; return; } @@ -149,7 +160,7 @@ function renderMemes(memes, options = {}) { ${relativeAge(meme.createdAt)}
- Uploaded meme ${shortId(meme.id)} + Uploaded meme ${shortId(meme.id)}
@@ -169,8 +180,10 @@ function renderMemes(memes, options = {}) { if (options.append) { grid.append(...cards); } else { + unobserveViewedImages(); grid.replaceChildren(...cards); } + observeViewedImages(cards); } async function validateClientFile(file) { @@ -211,13 +224,106 @@ function closeUpload() { uploadModal.close(); } -async function recordView(id) { +function handleViewIntersections(entries) { + for (const entry of entries) { + const id = entry.target.dataset.viewObserveId; + if (!id || wasRecentlyViewed(id)) { + stopViewTimer(id); + if (wasRecentlyViewed(id)) viewObserver.unobserve(entry.target); + continue; + } + + if (entry.isIntersecting && entry.intersectionRatio >= VIEW_THRESHOLD) { + startViewTimer(id, entry.target); + } else { + stopViewTimer(id); + } + } +} + +function observeViewedImages(cards) { + if (!viewObserver) return; + for (const card of cards) { + const image = card.querySelector('[data-view-observe-id]'); + if (image && !wasRecentlyViewed(image.dataset.viewObserveId)) viewObserver.observe(image); + } +} + +function unobserveViewedImages() { + for (const timer of pendingViewTimers.values()) window.clearTimeout(timer); + pendingViewTimers.clear(); + if (!viewObserver) return; + for (const image of grid.querySelectorAll('[data-view-observe-id]')) { + viewObserver.unobserve(image); + } +} + +function startViewTimer(id, target) { + if (pendingViewTimers.has(id)) return; + const timer = window.setTimeout(async () => { + pendingViewTimers.delete(id); + if (wasRecentlyViewed(id)) { + viewObserver.unobserve(target); + return; + } + await recordViewOnce(id); + viewObserver.unobserve(target); + }, VIEW_DWELL_MS); + pendingViewTimers.set(id, timer); +} + +function stopViewTimer(id) { + const timer = pendingViewTimers.get(id); + if (!timer) return; + window.clearTimeout(timer); + pendingViewTimers.delete(id); +} + +async function recordViewOnce(id) { + if (!isMemeId(id)) return; + if (wasRecentlyViewed(id)) return; + rememberViewed(id); try { const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' }); const payload = await response.json(); - if (response.ok) updateCounters(payload.meme); + if (!response.ok) throw new Error(payload.error || 'View update failed.'); + updateCounters(payload.meme); } catch { - // Live counter updates are best-effort; the lightbox should still open. + forgetViewed(id); + // Live counter updates are best-effort; the stream and lightbox should still work. + } +} + +function isMemeId(id) { + return typeof id === 'string' && /^[a-f0-9]{64}$/.test(id); +} + +function wasRecentlyViewed(id) { + if (viewedThisSession.has(id)) return true; + try { + const viewedAt = Number.parseInt(localStorage.getItem(`${VIEW_DEDUPE_PREFIX}${id}`) || '0', 10); + if (Number.isFinite(viewedAt) && Date.now() - viewedAt < VIEW_DEDUPE_MS) return true; + } catch { + // Storage can be unavailable in hardened browser modes; session memory still dedupes. + } + return false; +} + +function rememberViewed(id) { + viewedThisSession.add(id); + try { + localStorage.setItem(`${VIEW_DEDUPE_PREFIX}${id}`, String(Date.now())); + } catch { + // localStorage is optional; no upload or view flow depends on it. + } +} + +function forgetViewed(id) { + viewedThisSession.delete(id); + try { + localStorage.removeItem(`${VIEW_DEDUPE_PREFIX}${id}`); + } catch { + // Optional storage cleanup. } }