Count views on stream

This commit is contained in:
2026-05-09 13:31:25 -03:00
parent 908084c394
commit e5fa16a88d
+111 -5
View File
@@ -1,5 +1,9 @@
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
const MAX_FILE_BYTES = 5 * 1024 * 1024; 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 SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid'); const grid = document.querySelector('#meme-grid');
@@ -21,6 +25,8 @@ let currentPage = 0;
let totalPages = 1; let totalPages = 1;
let isLoading = false; let isLoading = false;
let latestValidationRun = 0; let latestValidationRun = 0;
const viewedThisSession = new Set();
const pendingViewTimers = new Map();
document.querySelector('#open-upload').addEventListener('click', () => { document.querySelector('#open-upload').addEventListener('click', () => {
formStatus.textContent = ''; formStatus.textContent = '';
@@ -83,7 +89,7 @@ grid.addEventListener('click', async (event) => {
lightboxDownload.href = viewButton.dataset.downloadUrl; lightboxDownload.href = viewButton.dataset.downloadUrl;
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`; lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
lightbox.showModal(); lightbox.showModal();
await recordView(viewButton.dataset.viewId); await recordViewOnce(viewButton.dataset.viewId);
}); });
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
@@ -92,6 +98,10 @@ const observer = new IntersectionObserver((entries) => {
} }
}, { rootMargin: '420px 0px' }); }, { rootMargin: '420px 0px' });
const viewObserver = 'IntersectionObserver' in window
? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] })
: null;
connectLiveCounters(); connectLiveCounters();
updateScrollIndicator(); updateScrollIndicator();
window.addEventListener('scroll', updateScrollIndicator, { passive: true }); window.addEventListener('scroll', updateScrollIndicator, { passive: true });
@@ -132,6 +142,7 @@ async function loadMemes(page, options = {}) {
function renderMemes(memes, options = {}) { function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) { if (!options.append && memes.length === 0) {
unobserveViewedImages();
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`; grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
return; return;
} }
@@ -149,7 +160,7 @@ function renderMemes(memes, options = {}) {
<span class="card-meta age">${relativeAge(meme.createdAt)}</span> <span class="card-meta age">${relativeAge(meme.createdAt)}</span>
</div> </div>
<div class="image-frame"> <div class="image-frame">
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy"> <img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy" data-view-observe-id="${meme.id}">
</div> </div>
<div class="card-actions"> <div class="card-actions">
<div class="stats"> <div class="stats">
@@ -169,8 +180,10 @@ function renderMemes(memes, options = {}) {
if (options.append) { if (options.append) {
grid.append(...cards); grid.append(...cards);
} else { } else {
unobserveViewedImages();
grid.replaceChildren(...cards); grid.replaceChildren(...cards);
} }
observeViewedImages(cards);
} }
async function validateClientFile(file) { async function validateClientFile(file) {
@@ -211,13 +224,106 @@ function closeUpload() {
uploadModal.close(); 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 { try {
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' }); const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
const payload = await response.json(); 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 { } 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.
} }
} }