Count views on stream
This commit is contained in:
+111
-5
@@ -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 = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -149,7 +160,7 @@ function renderMemes(memes, options = {}) {
|
||||
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
||||
</div>
|
||||
<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 class="card-actions">
|
||||
<div class="stats">
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user