f0ck algos
This commit is contained in:
+200
-3
@@ -4739,6 +4739,7 @@ window.cancelAnimFrame = (function () {
|
||||
// Special check for random
|
||||
if (link.id === 'random' || link.id === 'nav-random') {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent('f0ck:randomTriggered'));
|
||||
|
||||
const outEls = document.querySelectorAll('.content, ._204863, .item_title');
|
||||
outEls.forEach(el => {
|
||||
@@ -4970,9 +4971,9 @@ window.cancelAnimFrame = (function () {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItemLink = isItemPath(pathname);
|
||||
if (isItemLink) {
|
||||
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
||||
const _inComment = anyLink.closest(".comment, .comment-content, .comment-body, .motd-content");
|
||||
loadItemAjax(targetUrl, !_inComment);
|
||||
// Links inside comment bodies, MOTD, or sidebar suggestions/recommendations should not inherit tag/hall/user context
|
||||
const _skipInherit = anyLink.dataset.inheritContext === 'false' || anyLink.closest(".comment, .comment-content, .comment-body, .motd-content, .sidebar-video-card, .sidebar-recommendations-list, #sidebar-recommendations-container");
|
||||
loadItemAjax(targetUrl, !_skipInherit);
|
||||
} else {
|
||||
const isBrand = anyLink.classList.contains('navbar-brand');
|
||||
const fromModal = document.body.classList.contains('onara-modal-open') || !!anyLink.closest('#onara-modal');
|
||||
@@ -13192,3 +13193,199 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── User Interest & Behavioral Recommendation Engine (Client Tracking) ──
|
||||
(function() {
|
||||
const STORAGE_TAGS = 'f0ck_guest_tag_affinity';
|
||||
const STORAGE_CREATORS = 'f0ck_guest_creator_affinity';
|
||||
|
||||
let currentTrackingItem = null;
|
||||
|
||||
const loadStoredMap = (key) => {
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveStoredMap = (key, map) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(map));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const updateLocalAffinity = (key, name, delta) => {
|
||||
if (!name || typeof name !== 'string') return;
|
||||
const cleanName = name.trim().toLowerCase();
|
||||
if (!cleanName) return;
|
||||
|
||||
const map = loadStoredMap(key);
|
||||
const existing = map[cleanName] || { score: 0, count: 0, ts: Date.now() };
|
||||
existing.score = Math.max(-10, Math.min(500, (existing.score || 0) + delta));
|
||||
existing.count = (existing.count || 0) + 1;
|
||||
existing.ts = Date.now();
|
||||
map[cleanName] = existing;
|
||||
|
||||
// Prune to top 60 to prevent storage bloat
|
||||
const entries = Object.entries(map);
|
||||
if (entries.length > 60) {
|
||||
entries.sort((a, b) => b[1].score - a[1].score);
|
||||
const pruned = Object.fromEntries(entries.slice(0, 50));
|
||||
saveStoredMap(key, pruned);
|
||||
} else {
|
||||
saveStoredMap(key, map);
|
||||
}
|
||||
};
|
||||
|
||||
const f0ckInterestEngine = {
|
||||
getTopAffinities: () => {
|
||||
const tagsMap = loadStoredMap(STORAGE_TAGS);
|
||||
const creatorsMap = loadStoredMap(STORAGE_CREATORS);
|
||||
|
||||
const topTags = Object.entries(tagsMap)
|
||||
.filter(([, v]) => v.score > 0)
|
||||
.sort((a, b) => b[1].score - a[1].score)
|
||||
.slice(0, 15)
|
||||
.map(([k]) => k);
|
||||
|
||||
const topCreators = Object.entries(creatorsMap)
|
||||
.filter(([, v]) => v.score > 0)
|
||||
.sort((a, b) => b[1].score - a[1].score)
|
||||
.slice(0, 10)
|
||||
.map(([k]) => k);
|
||||
|
||||
return { topTags, topCreators };
|
||||
},
|
||||
|
||||
recordInteraction: ({ itemId, tags = [], creator = '', duration = 0, percent = 0, type = 'dwell' }) => {
|
||||
if (!itemId) return;
|
||||
|
||||
// Behavioral weighting: time spent is a primary signal
|
||||
let delta = 1.0;
|
||||
if (type === 'skip' || (duration < 2.5 && percent < 15)) {
|
||||
delta = -1.5;
|
||||
} else if (type === 'click_suggestion') {
|
||||
delta = 2.5;
|
||||
} else if (type === 'finish' || percent >= 75 || duration >= 20) {
|
||||
delta = 3.0;
|
||||
} else if (duration >= 8 || percent >= 35) {
|
||||
delta = 1.5;
|
||||
}
|
||||
|
||||
// Update local storage (for persistent guest learning across browser restarts)
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach(t => updateLocalAffinity(STORAGE_TAGS, t, delta));
|
||||
}
|
||||
if (creator) {
|
||||
updateLocalAffinity(STORAGE_CREATORS, creator, delta);
|
||||
}
|
||||
|
||||
// Send beacon to backend
|
||||
const payload = {
|
||||
item_id: itemId,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
percent: Math.round(percent),
|
||||
type
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
||||
navigator.sendBeacon('/api/v2/track/interaction', blob);
|
||||
} else {
|
||||
fetch('/api/v2/track/interaction', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
recordSuggestionClick: (itemId, tags = [], creator = '') => {
|
||||
if (!itemId) return;
|
||||
f0ckInterestEngine.recordInteraction({ itemId, tags, creator, type: 'click_suggestion' });
|
||||
},
|
||||
|
||||
flushCurrentTracking: () => {
|
||||
if (!currentTrackingItem) return;
|
||||
const { itemId, startTime, maxPercent, tags, creator } = currentTrackingItem;
|
||||
currentTrackingItem = null;
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
let type = 'dwell';
|
||||
if (duration < 2.5 && maxPercent < 15) {
|
||||
type = 'skip';
|
||||
} else if (maxPercent >= 75 || duration >= 20) {
|
||||
type = 'finish';
|
||||
}
|
||||
|
||||
f0ckInterestEngine.recordInteraction({
|
||||
itemId,
|
||||
tags,
|
||||
creator,
|
||||
duration,
|
||||
percent: maxPercent,
|
||||
type
|
||||
});
|
||||
},
|
||||
|
||||
startTrackingItem: () => {
|
||||
f0ckInterestEngine.flushCurrentTracking();
|
||||
|
||||
// Only track if on an item page
|
||||
const itemIdEl = document.querySelector('[data-item-id]');
|
||||
const itemId = itemIdEl ? parseInt(itemIdEl.dataset.itemId, 10) : null;
|
||||
if (!itemId) return;
|
||||
|
||||
const tags = Array.from(document.querySelectorAll('#tags .tag-text, #tags .tag-badge, .tag-link'))
|
||||
.map(el => el.textContent.trim().replace(/^#/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const creatorEl = document.querySelector('#a_username, [data-username]');
|
||||
const creator = creatorEl ? (creatorEl.dataset.username || creatorEl.textContent.trim()) : '';
|
||||
|
||||
currentTrackingItem = {
|
||||
itemId,
|
||||
startTime: Date.now(),
|
||||
maxPercent: 0,
|
||||
tags,
|
||||
creator
|
||||
};
|
||||
|
||||
// Attach media playback listeners
|
||||
const media = document.querySelector('video, audio');
|
||||
if (media) {
|
||||
const updatePercent = () => {
|
||||
if (!currentTrackingItem || !media.duration) return;
|
||||
const p = (media.currentTime / media.duration) * 100;
|
||||
if (p > currentTrackingItem.maxPercent) {
|
||||
currentTrackingItem.maxPercent = p;
|
||||
}
|
||||
};
|
||||
|
||||
media.addEventListener('timeupdate', updatePercent, { passive: true });
|
||||
media.addEventListener('ended', () => {
|
||||
if (currentTrackingItem) currentTrackingItem.maxPercent = 100;
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.f0ckInterestEngine = f0ckInterestEngine;
|
||||
|
||||
// Listen to item content loads and page teardown
|
||||
document.addEventListener('f0ck:contentLoaded', f0ckInterestEngine.startTrackingItem);
|
||||
window.addEventListener('beforeunload', f0ckInterestEngine.flushCurrentTracking);
|
||||
window.addEventListener('pagehide', f0ckInterestEngine.flushCurrentTracking);
|
||||
|
||||
// Initial check on load
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
setTimeout(f0ckInterestEngine.startTrackingItem, 200);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(f0ckInterestEngine.startTrackingItem, 200));
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1044,6 +1044,13 @@
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
loadActivity(false); // Force reload with loading state
|
||||
|
||||
recommendationsLoaded = false;
|
||||
let savedTab = 'comments';
|
||||
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
|
||||
if (savedTab === 'videos') {
|
||||
loadVideoRecommendations(false);
|
||||
}
|
||||
} else {
|
||||
// Immediately render from cache to avoid flicker
|
||||
renderFromCache();
|
||||
@@ -1057,6 +1064,456 @@
|
||||
syncSidebarAndComments();
|
||||
}
|
||||
|
||||
// Video / Media Recommendations Logic
|
||||
let recommendationsLoading = false;
|
||||
let recommendationsLoadingMore = false;
|
||||
let recommendationsLoaded = false;
|
||||
let currentRecommendations = [];
|
||||
const seenRecommendationIds = new Set();
|
||||
const RECOMMENDATIONS_LIMIT = SIDEBAR_MAX_COMMENTS;
|
||||
let recIoSentinel = null;
|
||||
let recObserver = null;
|
||||
|
||||
const renderVideoCard = (video) => {
|
||||
const videoKey = (window.f0ckSession?.enable_item_slugs && video.slug) ? video.slug : video.id;
|
||||
const rClass = video.rating_class || 'untagged';
|
||||
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
||||
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
||||
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
||||
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
||||
|
||||
let isBlurred = false;
|
||||
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
|
||||
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
|
||||
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
|
||||
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
|
||||
|
||||
const mime = video.mime || '';
|
||||
const isAudio = mime.startsWith('audio/');
|
||||
const isVideo = mime.startsWith('video/');
|
||||
const isImage = mime.startsWith('image/');
|
||||
|
||||
let thumbUrl = `/t/${video.id}.webp`;
|
||||
if (isBlurred) {
|
||||
thumbUrl = `/t/${video.id}_blur.webp`;
|
||||
}
|
||||
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
||||
|
||||
let displayTitle = '';
|
||||
if (video.title && video.title.trim()) {
|
||||
displayTitle = video.title.trim();
|
||||
} else if (video.tags && video.tags.length > 0) {
|
||||
displayTitle = video.tags.map(t => '#' + t).join(' ');
|
||||
} else {
|
||||
const typeLabel = isAudio ? 'Audio' : (isVideo ? 'Video' : 'Image');
|
||||
displayTitle = `${typeLabel} #${video.id}`;
|
||||
}
|
||||
|
||||
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
|
||||
const authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username);
|
||||
const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : '';
|
||||
|
||||
const timeStr = video.stamp
|
||||
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(new Date(video.stamp * 1000).toISOString()) : '')
|
||||
: '';
|
||||
const fullDate = video.stamp ? new Date(video.stamp * 1000).toLocaleString() : '';
|
||||
|
||||
const xdBadge = (video.xd_score && video.xd_score > 0)
|
||||
? `<span class="sidebar-video-xd xd-tier-${video.xd_tier || 0}">xD ${video.xd_score}</span>`
|
||||
: '';
|
||||
|
||||
const ext = (video.mime ? video.mime.split('/')[1] : '')
|
||||
.replace('jpeg', 'jpg')
|
||||
.replace('x-shockwave-flash', 'flash')
|
||||
.replace('x-flac', 'flac')
|
||||
.replace('mpeg', 'mp3')
|
||||
.toUpperCase();
|
||||
const formatBadge = ext ? `<span class="sidebar-media-format-badge">${escapeHtml(ext)}</span>` : '';
|
||||
const overlayIcon = isAudio ? 'fa-solid fa-music' : (isVideo ? 'fa-solid fa-play' : 'fa-solid fa-image');
|
||||
|
||||
let thumbContentHtml = '';
|
||||
if (isAudio && !video.has_coverart) {
|
||||
thumbContentHtml = `
|
||||
<div class="sidebar-media-placeholder audio">
|
||||
<i class="fa-solid fa-music"></i>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
thumbContentHtml = `
|
||||
<img src="${thumbUrl}" class="sidebar-video-thumb" alt="${escapeHtml(displayTitle)}" loading="lazy" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');" />
|
||||
<div class="sidebar-media-placeholder ${isAudio ? 'audio' : ''} hidden">
|
||||
<i class="${overlayIcon}"></i>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const forYouBadge = video.personalized
|
||||
? `<span class="sidebar-personalized-pill" title="Personalized recommendation based on your interests"><i class="fa-solid fa-wand-magic-sparkles"></i> For You</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="sidebar-video-card" data-id="${video.id}" data-slug="${escapeHtml(video.slug || '')}" data-personalized="${video.personalized ? 'true' : 'false'}">
|
||||
<a href="/${videoKey}" class="sidebar-video-link" data-mode="${rClass}" data-inherit-context="false">
|
||||
<div class="sidebar-video-thumb-wrap">
|
||||
${thumbContentHtml}
|
||||
<div class="sidebar-video-play-overlay"><i class="${overlayIcon}"></i></div>
|
||||
${formatBadge}
|
||||
<span class="sidebar-video-badge rating-${rClass}">${rClass.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="sidebar-video-details">
|
||||
<div class="sidebar-video-info">
|
||||
<div class="sidebar-video-title" title="${escapeHtml(displayTitle)}">${escapeHtml(displayTitle)}</div>
|
||||
<div class="sidebar-video-channel">
|
||||
<span class="sidebar-video-user" ${userColorStyle}>${escapeHtml(authorName)}</span>
|
||||
</div>
|
||||
<div class="sidebar-video-meta">
|
||||
${timeStr ? `<span class="sidebar-video-time" title="${escapeHtml(fullDate)}">${escapeHtml(timeStr)}</span>` : ''}
|
||||
${(timeStr && xdBadge) ? `•` : ''}
|
||||
${xdBadge}
|
||||
${((timeStr || xdBadge) && forYouBadge) ? `•` : ''}
|
||||
${forYouBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const getSessionAffinityParams = () => {
|
||||
try {
|
||||
const aff = window.f0ckInterestEngine ? window.f0ckInterestEngine.getTopAffinities() : null;
|
||||
if (!aff) return '';
|
||||
let q = '';
|
||||
if (aff.topTags && aff.topTags.length > 0) {
|
||||
q += `&session_tags=${encodeURIComponent(aff.topTags.join(','))}`;
|
||||
}
|
||||
if (aff.topCreators && aff.topCreators.length > 0) {
|
||||
q += `&session_creators=${encodeURIComponent(aff.topCreators.join(','))}`;
|
||||
}
|
||||
return q;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecommendations = async (silent = false) => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container || recommendationsLoading) return;
|
||||
|
||||
recommendationsLoading = true;
|
||||
if (!silent && !recommendationsLoaded) {
|
||||
container.innerHTML = `
|
||||
<div class="sidebar-loading-state">
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
<span>${window.f0ckI18n?.sidebar_loading_recommendations || 'Loading recommendations...'}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
seenRecommendationIds.clear();
|
||||
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||
const affParams = getSessionAffinityParams();
|
||||
const res = await fetch(`/api/v2/recommendations?limit=${RECOMMENDATIONS_LIMIT}&mode=${mode}${affParams}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = data.items || data.videos || [];
|
||||
|
||||
if (data.success && items.length > 0) {
|
||||
currentRecommendations = items;
|
||||
recommendationsLoaded = true;
|
||||
|
||||
let html = '';
|
||||
items.forEach(v => {
|
||||
seenRecommendationIds.add(v.id);
|
||||
html += renderVideoCard(v);
|
||||
});
|
||||
container.innerHTML = html;
|
||||
if (silent) {
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
attachRecommendationSentinel();
|
||||
bindRecommendationEvents();
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div style="text-align:center;padding:20px;color:#888;">
|
||||
${window.f0ckI18n?.sidebar_no_recommendations || 'No recommendations found.'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Recommendations: Failed to load", e);
|
||||
if (!recommendationsLoaded) {
|
||||
container.innerHTML = `<div style="text-align:center;padding:20px;color:#888;">${window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.'}</div>`;
|
||||
}
|
||||
} finally {
|
||||
recommendationsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreRecommendations = async () => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container || recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return;
|
||||
|
||||
recommendationsLoadingMore = true;
|
||||
|
||||
let indicator = document.getElementById('sidebar-recommendations-load-more');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'sidebar-recommendations-load-more';
|
||||
indicator.style.cssText = 'text-align:center;padding:12px 0;font-size:0.85em;color:#888;';
|
||||
indicator.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i>';
|
||||
}
|
||||
container.appendChild(indicator);
|
||||
|
||||
try {
|
||||
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||
const excludeArr = Array.from(seenRecommendationIds).slice(-100);
|
||||
const affParams = getSessionAffinityParams();
|
||||
const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}&exclude_ids=${excludeArr.join(',')}${affParams}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = data.items || data.videos || [];
|
||||
|
||||
if (indicator && indicator.parentNode) {
|
||||
indicator.remove();
|
||||
}
|
||||
|
||||
if (data.success && items.length > 0) {
|
||||
const freshItems = items.filter(v => !seenRecommendationIds.has(v.id));
|
||||
if (freshItems.length > 0) {
|
||||
const temp = document.createElement('div');
|
||||
let html = '';
|
||||
freshItems.forEach(v => {
|
||||
seenRecommendationIds.add(v.id);
|
||||
html += renderVideoCard(v);
|
||||
});
|
||||
temp.innerHTML = html;
|
||||
while (temp.firstChild) {
|
||||
container.appendChild(temp.firstChild);
|
||||
}
|
||||
attachRecommendationSentinel();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Recommendations: Failed to load more", e);
|
||||
if (indicator && indicator.parentNode) {
|
||||
indicator.remove();
|
||||
}
|
||||
} finally {
|
||||
recommendationsLoadingMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
const attachRecommendationSentinel = () => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container) return;
|
||||
|
||||
if (!recIoSentinel) {
|
||||
recIoSentinel = document.createElement('div');
|
||||
recIoSentinel.id = 'sidebar-recommendations-io-sentinel';
|
||||
recIoSentinel.style.height = '1px';
|
||||
}
|
||||
|
||||
container.appendChild(recIoSentinel);
|
||||
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
if (!recObserver) {
|
||||
recObserver = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && recommendationsLoaded && !recommendationsLoading && !recommendationsLoadingMore) {
|
||||
loadMoreRecommendations();
|
||||
}
|
||||
}, { root: container, rootMargin: '0px 0px 250px 0px', threshold: 0 });
|
||||
}
|
||||
recObserver.disconnect();
|
||||
recObserver.observe(recIoSentinel);
|
||||
}
|
||||
};
|
||||
|
||||
const bindRecommendationScrollListener = () => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container) return;
|
||||
|
||||
// Fallback for environments without IntersectionObserver
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
container.addEventListener('scroll', () => {
|
||||
if (recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return;
|
||||
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 150;
|
||||
if (nearBottom) loadMoreRecommendations();
|
||||
}, { passive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoRecommendations = loadRecommendations;
|
||||
|
||||
const switchSidebarTab = (tabName) => {
|
||||
const normalizedTab = (tabName === 'videos' || tabName === 'recommendations') ? 'recommendations' : tabName;
|
||||
const tabs = document.querySelectorAll('.sidebar-tab');
|
||||
const contents = document.querySelectorAll('.sidebar-tab-content');
|
||||
|
||||
tabs.forEach(t => {
|
||||
const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations') ? 'recommendations' : t.dataset.tab;
|
||||
if (tTab === normalizedTab) {
|
||||
t.classList.add('active');
|
||||
} else {
|
||||
t.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
contents.forEach(c => {
|
||||
const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations') ? 'recommendations' : c.dataset.tabContent;
|
||||
if (cTab === normalizedTab) {
|
||||
c.classList.add('active');
|
||||
c.style.display = 'block';
|
||||
} else {
|
||||
c.classList.remove('active');
|
||||
c.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem('sidebar_active_tab', normalizedTab);
|
||||
} catch (_) {}
|
||||
|
||||
if (normalizedTab === 'recommendations') {
|
||||
if (!recommendationsLoaded) {
|
||||
loadRecommendations();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initSidebarTabs = () => {
|
||||
const tabsContainer = document.querySelector('.sidebar-tabs');
|
||||
if (tabsContainer) {
|
||||
tabsContainer.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.sidebar-tab');
|
||||
if (btn && btn.dataset.tab) {
|
||||
e.preventDefault();
|
||||
switchSidebarTab(btn.dataset.tab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore persisted active tab if any
|
||||
let savedTab = 'comments';
|
||||
try {
|
||||
savedTab = localStorage.getItem('sidebar_active_tab') || 'comments';
|
||||
} catch (_) {}
|
||||
|
||||
if (savedTab === 'videos' || savedTab === 'recommendations') {
|
||||
switchSidebarTab('recommendations');
|
||||
}
|
||||
};
|
||||
|
||||
const replaceCardWithNewRandom = async (card) => {
|
||||
if (!card || card.dataset.swapping === 'true') return;
|
||||
card.dataset.swapping = 'true';
|
||||
card.classList.add('sidebar-card-swapping');
|
||||
|
||||
const clickedId = parseInt(card.dataset.id, 10);
|
||||
|
||||
try {
|
||||
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
const visibleIds = [];
|
||||
if (container) {
|
||||
container.querySelectorAll('.sidebar-video-card').forEach(c => {
|
||||
const cid = parseInt(c.dataset.id, 10);
|
||||
if (cid) visibleIds.push(cid);
|
||||
});
|
||||
}
|
||||
|
||||
const excludeSet = new Set([...seenRecommendationIds, ...visibleIds]);
|
||||
if (clickedId) {
|
||||
excludeSet.add(clickedId);
|
||||
if (window.f0ckInterestEngine) {
|
||||
window.f0ckInterestEngine.recordSuggestionClick(clickedId);
|
||||
}
|
||||
}
|
||||
const excludeArr = Array.from(excludeSet).slice(-120);
|
||||
|
||||
const affParams = getSessionAffinityParams();
|
||||
const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}&exclude_ids=${excludeArr.join(',')}${affParams}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = data.items || data.videos || [];
|
||||
|
||||
if (data.success && items.length > 0) {
|
||||
const newItem = items[0];
|
||||
seenRecommendationIds.add(newItem.id);
|
||||
|
||||
const idx = currentRecommendations.findIndex(v => v.id === clickedId);
|
||||
if (idx !== -1) {
|
||||
currentRecommendations[idx] = newItem;
|
||||
} else {
|
||||
currentRecommendations.push(newItem);
|
||||
}
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = renderVideoCard(newItem).trim();
|
||||
const newCard = temp.firstElementChild;
|
||||
|
||||
if (newCard && card.parentNode) {
|
||||
newCard.classList.add('sidebar-card-entering');
|
||||
card.replaceWith(newCard);
|
||||
setTimeout(() => {
|
||||
newCard.classList.remove('sidebar-card-entering');
|
||||
}, 2200);
|
||||
}
|
||||
} else {
|
||||
card.classList.remove('sidebar-card-swapping');
|
||||
delete card.dataset.swapping;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Recommendations: Failed to swap clicked card", e);
|
||||
if (card) {
|
||||
card.classList.remove('sidebar-card-swapping');
|
||||
delete card.dataset.swapping;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bindRecommendationEvents = () => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container || container.dataset.eventsBound === 'true') return;
|
||||
container.dataset.eventsBound = 'true';
|
||||
|
||||
const handleCardAction = (e) => {
|
||||
// Only respond to primary click (0) or middle-click (1). Ignore right click (2).
|
||||
if (e.button !== 0 && e.button !== 1) return;
|
||||
|
||||
const card = e.target.closest('.sidebar-video-card');
|
||||
if (card) {
|
||||
replaceCardWithNewRandom(card);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleCardAction);
|
||||
container.addEventListener('auxclick', handleCardAction);
|
||||
};
|
||||
|
||||
// Reload recommendations when user triggers Random (#random, #nav-random, or 'r' key)
|
||||
const handleRandomAction = () => {
|
||||
recommendationsLoaded = false;
|
||||
let savedTab = 'comments';
|
||||
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
|
||||
if (savedTab === 'videos' || savedTab === 'recommendations') {
|
||||
loadRecommendations(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('f0ck:randomTriggered', handleRandomAction);
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('#random, #nav-random, a[href="/random"], a[href$="/random"]');
|
||||
if (btn) {
|
||||
handleRandomAction();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
|
||||
document.addEventListener('f0ck:modeChanged', (e) => {
|
||||
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||
@@ -1066,6 +1523,13 @@
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
loadActivity(false);
|
||||
|
||||
recommendationsLoaded = false;
|
||||
let savedTab = 'comments';
|
||||
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
|
||||
if (savedTab === 'videos' || savedTab === 'recommendations') {
|
||||
loadRecommendations(false);
|
||||
}
|
||||
});
|
||||
|
||||
// When the current user posts a comment, silently refresh sidebar to show it
|
||||
@@ -1108,6 +1572,9 @@
|
||||
// Initial load
|
||||
const _origInit = init;
|
||||
const initWithScroll = async () => {
|
||||
initSidebarTabs();
|
||||
bindRecommendationEvents();
|
||||
bindRecommendationScrollListener();
|
||||
await _origInit();
|
||||
bindScrollListener();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user