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)}
-

+
@@ -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.
}
}