updating from dev

This commit is contained in:
2026-05-04 04:24:18 +02:00
parent 46afca976d
commit 2f1e42343b
76 changed files with 5554 additions and 2527 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,17 +19,19 @@
transform: translateY(0);
}
}
.upload-container h2 {
margin-bottom: 0.5rem;
color: var(--accent);
text-align: center;
}
.upload-title {
font-size: x-large;
}
/* Upload Limit Info */
.upload-limit-info {
text-align: center;
margin-bottom: 1.5rem;
font-size: 0.9rem;
opacity: 0.7;
}
@@ -56,7 +58,6 @@
.upload-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: rgba(255, 255, 255, 0.02);
padding: 1rem;
border-radius: 0;
@@ -82,7 +83,7 @@
cursor: pointer;
transition: all 0.2s;
position: relative;
min-height: 200px;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
@@ -282,6 +283,7 @@
position: relative;
gap: 0.5rem;
z-index: 10000 !important;
margin-bottom: 5px;
}
.tags-list {
@@ -337,6 +339,7 @@
/* Upload Comment */
.upload-comment-input {
position: relative;
margin: 0px 0px 5px 0px;
}
.upload-comment {
@@ -734,14 +737,6 @@
color: rgba(255, 255, 255, 0.8);
}
.meta-suggestion:hover {
background: var(--accent);
border-color: var(--accent);
color: white !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
}
.meta-suggestion i {
font-size: 0.7rem;
opacity: 0.6;

BIN
public/s/img/pdf.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -290,7 +290,7 @@
old.innerText = res.tag.trim();
break;
default:
console.log(res);
window.f0ckDebug(res);
break;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
const showModal = () => {
if (!dragModal) return;
dragModal.classList.add('show');
document.body.classList.add('modal-open');
// Reset scroll position so it always starts at the top
dragModal.scrollTop = 0;
const modalContent = dragModal.querySelector('.modal-content');
@@ -89,6 +90,7 @@
// Modal Close
dragModalClose.onclick = () => {
dragModal.classList.remove('show');
document.body.classList.remove('modal-open');
if (uploader && uploader.reset) {
uploader.reset();
}

View File

@@ -40,12 +40,92 @@ window.cancelAnimFrame = (function () {
} catch(e) { console.error('Visit tracking error:', e); }
};
window.applyThumbCacheBust = (bgUrlStr) => {
if (!bgUrlStr) return bgUrlStr;
try {
const bustedStr = localStorage.getItem('bustedThumbs');
if (!bustedStr) return bgUrlStr;
const busted = JSON.parse(bustedStr);
const match = bgUrlStr.match(/\/t\/(\d+)(?:_blur)?\.webp/);
if (match) {
const id = match[1];
if (busted[id]) {
const url = new URL(bgUrlStr, window.location.origin);
url.searchParams.set('t', busted[id]);
return url.pathname + url.search;
}
}
} catch(e) {}
return bgUrlStr;
};
/**
* Forcefully refreshes all thumbnail occurrences for a specific item in the DOM.
* Handles grid items (data-bg), images (src), and the background canvas.
*/
window.refreshItemThumbnails = (itemId, timestamp = Date.now()) => {
if (!itemId) return;
const idStr = String(itemId);
// Update localStorage so future navigations use the new timestamp
try {
const bustedStr = localStorage.getItem('bustedThumbs');
const busted = bustedStr ? JSON.parse(bustedStr) : {};
busted[idStr] = timestamp;
const keys = Object.keys(busted);
if (keys.length > 50) delete busted[keys[0]];
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
} catch(e) {}
// Clear grid cache to force fresh render on next navigation
if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear();
// Update elements with data-bg (grid items).
// We look for any data-bg or inline style containing the thumbnail path for this ID.
document.querySelectorAll(`[data-bg*="/t/${idStr}.webp"], [data-bg*="/t/${idStr}_blur.webp"], [style*="/t/${idStr}.webp"], [style*="/t/${idStr}_blur.webp"]`).forEach(el => {
// If it has data-bg, update it (this handles lazy-thumb logic)
if (el.dataset.bg) {
el.dataset.bg = window.applyThumbCacheBust(el.dataset.bg);
}
// If it's already showing the background, update the style directly
if (el.style.backgroundImage || el.getAttribute('style')?.includes('background-image')) {
const currentStyle = el.getAttribute('style') || '';
// Match url(...) contents
const newStyle = currentStyle.replace(/url\(['"]?([^'"]+)['"]?\)/g, (match, p1) => {
if (p1.includes(`/t/${idStr}.webp`) || p1.includes(`/t/${idStr}_blur.webp`)) {
return `url('${window.applyThumbCacheBust(p1)}')`;
}
return match;
});
el.setAttribute('style', newStyle);
}
});
// Update actual img tags
document.querySelectorAll(`img[src*="/t/${idStr}.webp"], img[src*="/t/${idStr}_blur.webp"]`).forEach(el => {
try {
const url = new URL(el.src, window.location.origin);
url.searchParams.set('t', timestamp);
el.src = url.pathname + url.search;
} catch(e) {}
});
// Refresh background canvas if it matches the current item
const pathParts = window.location.pathname.split('/');
const numParts = pathParts.filter(s => /^\d+$/.test(s));
const currentId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
if (currentId === idStr && window.initBackground) {
window.initBackground();
}
};
let lazyObserver;
window.initLazyLoading = () => {
if (!('IntersectionObserver' in window)) {
document.querySelectorAll('.lazy-thumb').forEach(thumb => {
if (thumb.dataset.bg) {
thumb.style.backgroundImage = `url('${thumb.dataset.bg}')`;
const finalBg = window.applyThumbCacheBust(thumb.dataset.bg);
thumb.style.backgroundImage = `url('${finalBg}')`;
thumb.classList.remove('lazy-thumb');
}
});
@@ -57,8 +137,9 @@ window.cancelAnimFrame = (function () {
entries.forEach(entry => {
if (entry.isIntersecting) {
const thumb = entry.target;
const bg = thumb.dataset.bg;
let bg = thumb.dataset.bg;
if (bg && !thumb.classList.contains('loaded')) {
bg = window.applyThumbCacheBust(bg);
const img = new Image();
img.onload = () => {
thumb.style.backgroundImage = `url('${bg}')`;
@@ -293,11 +374,61 @@ window.cancelAnimFrame = (function () {
if (!modal) return;
if (modal === loginModal) switchModalView(view);
modal.style.display = 'flex';
document.body.classList.add('modal-open');
if (visitorMenu) visitorMenu.classList.remove('show');
};
const closeModal = (modal) => {
if (modal) modal.style.display = 'none';
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
};
/**
* Surgical cleanup of scroll-lock state and modal visibility.
* Used during AJAX navigation to ensure the UI remains interactive.
*/
window.resetGlobalScrollState = () => {
document.body.classList.remove('modal-open');
document.documentElement.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.height = '';
document.documentElement.style.overflow = '';
document.documentElement.style.height = '';
const pw = document.querySelector('.pagewrapper');
if (pw) {
pw.style.overflow = '';
pw.style.height = '';
}
};
window.hideAllModals = () => {
const modalIds = [
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
'content-warning-modal', 'gchat-img-modal', 'image-modal'
];
modalIds.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.classList.remove('show', 'visible');
// If the modal uses CSS classes for visibility, we must clear the inline display
// to allow those classes to work later. For others, we force display: none.
if (['upload-drag-modal', 'image-modal', 'gchat-img-modal', 'excluded-tags-overlay'].includes(id)) {
el.style.display = '';
} else {
el.style.display = 'none';
}
}
});
// Also handle class-based modals if any
document.querySelectorAll('.modal-overlay, .modal-backdrop').forEach(el => {
el.classList.remove('show', 'visible');
// Do NOT set display: none here as it might override CSS-based visibility
// for modals that use the classes we just removed.
});
};
if (loginModal) {
@@ -512,6 +643,8 @@ window.cancelAnimFrame = (function () {
}
}
if (registerBtn && registerModal) {
registerBtn.addEventListener('click', (e) => {
e.preventDefault();
@@ -844,7 +977,9 @@ window.cancelAnimFrame = (function () {
}
// For audio-only items with no thumbnail, canvas stays blank (nothing to draw)
};
thumb.src = `/t/${itemId}.webp`;
let newSrc = `/t/${itemId}.webp`;
if (window.applyThumbCacheBust) newSrc = window.applyThumbCacheBust(newSrc);
thumb.src = newSrc;
} else if (isDrawable) {
// No item ID — fall back to waiting for the main image
if (elem.complete) {
@@ -1016,8 +1151,7 @@ window.cancelAnimFrame = (function () {
if (cwModal) {
if (!localStorage.getItem('content_warning_accepted')) {
cwModal.style.display = 'flex';
document.body.style.overflow = 'hidden'; // Prevent scrolling on body
document.documentElement.style.overflow = 'hidden'; // Prevent scrolling on html
document.body.classList.add('modal-open');
}
const acceptBtn = document.getElementById('cw-accept');
@@ -1027,8 +1161,7 @@ window.cancelAnimFrame = (function () {
acceptBtn.addEventListener('click', () => {
localStorage.setItem('content_warning_accepted', 'true');
cwModal.style.display = 'none';
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
document.body.classList.remove('modal-open');
});
}
@@ -1079,7 +1212,7 @@ window.cancelAnimFrame = (function () {
const applyRuffleKeepAlive = () => {
if (ruffleKeepAliveApplied) return;
console.log("[Ruffle] Registering background keep-alive patches (Browser Level)...");
window.f0ckDebug("[Ruffle] Registering background keep-alive patches (Browser Level)...");
try {
const docProto = Object.getPrototypeOf(document);
@@ -1422,6 +1555,11 @@ window.cancelAnimFrame = (function () {
const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return;
// Immediately restore scrollability and hide modals
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
isNavigating = true;
// ── Scroller-active cleanup ──────────────────────────────────────────────
@@ -1793,7 +1931,7 @@ window.cancelAnimFrame = (function () {
document.body.style.height = '';
document.body.style.minHeight = '';
console.log("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
window.f0ckDebug("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
if (window.updateMimeLabel) window.updateMimeLabel();
}
@@ -2287,7 +2425,7 @@ window.cancelAnimFrame = (function () {
m.removeAttribute('src');
m.preload = 'none'; // Prevent further buffering
// m.load(); // Intentionally removed: calling load() with no src can fetch current page URL
console.log("Media aborted:", m);
window.f0ckDebug("Media aborted:", m);
} catch (e) { console.error("Error stopping media:", e); }
});
};
@@ -2309,7 +2447,7 @@ window.cancelAnimFrame = (function () {
try {
const fetchUrl = `/ajax/item/${itemid}?${params.toString()}`;
console.log(`[updateNavForMode] mode=${mode} itemid=${itemid}${fetchUrl}`);
window.f0ckDebug(`[updateNavForMode] mode=${mode} itemid=${itemid}${fetchUrl}`);
const resp = await fetch(fetchUrl, { credentials: 'include' });
if (!resp.ok) { console.warn('[updateNavForMode] bad response:', resp.status); return; }
const data = await resp.json();
@@ -2326,14 +2464,14 @@ window.cancelAnimFrame = (function () {
if (livePrev && newPrev) {
const h = newPrev.getAttribute('href');
livePrev.setAttribute('href', h);
console.log(`[updateNavForMode] #prev → ${h}`);
window.f0ckDebug(`[updateNavForMode] #prev → ${h}`);
} else {
console.warn(`[updateNavForMode] #prev missing: live=${!!livePrev} new=${!!newPrev}`);
}
if (liveNext && newNext) {
const h = newNext.getAttribute('href');
liveNext.setAttribute('href', h);
console.log(`[updateNavForMode] #next → ${h}`);
window.f0ckDebug(`[updateNavForMode] #next → ${h}`);
} else {
console.warn(`[updateNavForMode] #next missing: live=${!!liveNext} new=${!!newNext}`);
}
@@ -2353,6 +2491,11 @@ window.cancelAnimFrame = (function () {
const loadItemAjax = async (url, inheritContext = true, options = {}) => {
if (isNavigating) return;
// Immediately restore scrollability and hide modals
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
isNavigating = true;
// ── Scroller-active cleanup (same as loadPageAjax) ───────────────────
@@ -2534,7 +2677,7 @@ window.cancelAnimFrame = (function () {
if (window.randomizeLogo) window.randomizeLogo();
console.log("Fetching:", ajaxUrl);
window.f0ckDebug("Fetching:", ajaxUrl);
const tStart = performance.now();
const response = await fetch(ajaxUrl, { credentials: 'include' });
const tHeaders = performance.now();
@@ -2544,7 +2687,7 @@ window.cancelAnimFrame = (function () {
const rawText = await response.text();
const tBody = performance.now();
console.log(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
window.f0ckDebug(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
- TTFB (Headers): ${(tHeaders - tStart).toFixed(2)}ms
- Content Download: ${(tBody - tHeaders).toFixed(2)}ms
- Total Network: ${(tBody - tStart).toFixed(2)}ms
@@ -2705,7 +2848,7 @@ window.cancelAnimFrame = (function () {
// Try to extract ID from response if possible or just use itemid
document.title = `${window.f0ckDomain} - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork");
console.log("AJAX load complete");
window.f0ckDebug("AJAX load complete");
// Notify extensions — also triggers CommentSystem init which renders comments
document.dispatchEvent(new Event('f0ck:contentLoaded'));
@@ -2956,8 +3099,10 @@ window.cancelAnimFrame = (function () {
if (window.f0ckSession && window.f0ckSession.logged_in) {
window.showFlash('Already logged in lol', 'error');
} else {
loadPageAjax(anyLink.href, true);
if (pathname === '/login') openModal(loginModal, 'login');
else openModal(registerModal);
}
return false;
}
@@ -3067,6 +3212,9 @@ window.cancelAnimFrame = (function () {
fileInput.value = '';
if (data.success) {
window.flashMessage(data.msg);
if (window.refreshItemThumbnails) {
window.refreshItemThumbnails(currentItemId);
}
} else {
window.flashError(data.msg || 'Upload failed');
}
@@ -3163,7 +3311,7 @@ window.cancelAnimFrame = (function () {
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
console.log("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
if (isItem) {
loadItemAjax(url, true, { skipPush: true });
@@ -4280,6 +4428,7 @@ window.cancelAnimFrame = (function () {
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (window.location.pathname.startsWith('/abyss')) return;
e.preventDefault();
toggleModal(!overlay.classList.contains('visible'));
}
});
@@ -4510,6 +4659,7 @@ window.cancelAnimFrame = (function () {
};
window.updateXdBadgeFromScore = (itemId, score) => {
if (window.f0ckSession && window.f0ckSession.enable_xd_score === false) return;
updateItemPageXdBadge(itemId, score);
updateThumbXdIndicator(itemId, score);
};
@@ -5143,7 +5293,7 @@ class NotificationSystem {
fetch(`/api/notifications/active?tabId=${this.tabId}`).catch(() => {});
// If SSE is closed, reconnect
if (!this.es || this.es.readyState === 2) {
console.log("[NotificationSystem] SSE was closed, reconnecting as active tab...");
window.f0ckDebug("[NotificationSystem] SSE was closed, reconnecting as active tab...");
this.initSSE();
}
};
@@ -5195,17 +5345,19 @@ class NotificationSystem {
});
}
console.log("[NotificationSystem] Tab visible, signaling active...");
window.f0ckDebug("[NotificationSystem] Tab visible, signaling active...");
// If SSE died while hidden, restart it now
if (!this.es) {
console.log("[NotificationSystem] SSE was dead, restarting on tab visible.");
window.f0ckDebug("[NotificationSystem] SSE was dead, restarting on tab visible.");
this.retryCount = 0;
this.initSSE();
}
if (this.pollDebounced) this.pollDebounced();
if (this.checkForNewItems) this.checkForNewItems();
// Catch-up on emojis if they were updated while this tab was pruned/backgrounded
window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
// Note: emojis_updated dispatch was removed from here — it caused emoji cache flush +
// async re-fetch + re-render that could fight the scroll-position restoration.
// Emojis rarely change while a tab is hidden; SSE will deliver a targeted emojis_updated
// event if they actually changed.
signalActive();
// Sync display name in case it was changed while this tab was inactive
if (window.f0ckSession?.logged_in) this.syncDisplayName();
@@ -5241,11 +5393,11 @@ class NotificationSystem {
this.es.close();
}
console.log(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
window.f0ckDebug(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
this.es = new EventSource(`/api/notifications/stream?tabId=${this.tabId}`);
this.es.onopen = () => {
console.log("[NotificationSystem] SSE connection established");
window.f0ckDebug("[NotificationSystem] SSE connection established");
this.retryCount = 0;
document.documentElement.dataset.sseReady = '1';
document.dispatchEvent(new CustomEvent('f0ck:sse_ready'));
@@ -5254,16 +5406,17 @@ class NotificationSystem {
this.es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
console.log(`[SSE] Received message:`, data.type);
window.f0ckDebug(`[SSE] Received message:`, data.type);
if (data.type === 'notify') {
this.pollDebounced();
const dnd = window.f0ckSession?.do_not_disturb === true;
// Haptic feedback on mobile (supported: Chrome for Android, not iOS)
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
if (!dnd && navigator.vibrate) navigator.vibrate([200, 80, 200]);
// Live Grid Highlight
if (data.data && data.data.item_id) {
const itemId = data.data.item_id;
const notifType = data.data.type;
console.log(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
window.f0ckDebug(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
// System notifications (deletion, deny, reports) require explicit acknowledgment —
// never auto-mark them as read just because the user is viewing that item.
@@ -5273,7 +5426,7 @@ class NotificationSystem {
// (they are live on the thread, so no need to show a badge/highlight)
const currentPath = window.location.pathname;
if (!isSystemNotif && (currentPath === `/${itemId}` || currentPath === `/${itemId}/`)) {
console.log(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
window.f0ckDebug(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
fetch(`/api/notifications/item/${itemId}/read`, {
method: 'POST',
keepalive: true
@@ -5307,13 +5460,13 @@ class NotificationSystem {
}
this.handleActivity(data.data);
} else if (data.type === 'tags') {
console.log(`[SSE] Tag update received for item ${data.data?.item_id}`);
window.f0ckDebug(`[SSE] Tag update received for item ${data.data?.item_id}`);
this.handleTagsUpdate(data.data);
} else if (data.type === 'favorites') {
console.log(`[SSE] Favorite update received for item ${data.data?.item_id}`);
window.f0ckDebug(`[SSE] Favorite update received for item ${data.data?.item_id}`);
this.handleFavoritesUpdate(data.data);
} else if (data.type === 'comments') {
console.log(`[SSE] Comment update received:`, data.data);
window.f0ckDebug(`[SSE] Comment update received:`, data.data);
if (data.data.type === 'comment') {
// New comment posted — update xD badge from server-authoritative score
if (typeof data.data.xd_score === 'number') {
@@ -5339,21 +5492,26 @@ class NotificationSystem {
window.dispatchEvent(new CustomEvent('f0ck:comment_edited', { detail: data.data }));
}
} else if (data.type === 'emojis_updated') {
console.log("[SSE] Emojis updated, refreshing caches...");
window.f0ckDebug("[SSE] Emojis updated, refreshing caches...");
this.loadEmojis();
window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
} else if (data.type === 'motd') {
console.log(`[SSE] MOTD update received:`, data.data.motd);
window.f0ckDebug(`[SSE] MOTD update received:`, data.data.motd);
if (typeof window.updateMotdUI === 'function') {
window.updateMotdUI(data.data.motd);
}
} else if (data.type === 'rethumb') {
window.f0ckDebug(`[SSE] Rethumb update received for item ${data.data?.item_id}`);
if (data.data && data.data.item_id && window.refreshItemThumbnails) {
window.refreshItemThumbnails(data.data.item_id);
}
} else if (data.type === 'new_item') {
console.log(`[SSE] New item received:`, data.data);
window.f0ckDebug(`[SSE] New item received:`, data.data);
this.handleNewItem(data.data);
} else if (data.type === 'delete_item') {
const delId = data.data?.id;
if (!delId) return;
console.log(`[SSE] Item deleted: ${delId}`);
window.f0ckDebug(`[SSE] Item deleted: ${delId}`);
// Remove from main grid — a.thumb is the anchor, li is its parent card
const thumb = document.querySelector(`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`);
@@ -5389,22 +5547,23 @@ class NotificationSystem {
// Remove from sidebar activity if present
document.querySelectorAll(`#sidebar-activity-container [data-item="${delId}"]`).forEach(el => el.remove());
} else if (data.type === 'emojis_updated') {
console.log(`[SSE] Emoji update event received`);
window.f0ckDebug(`[SSE] Emoji update event received`);
if (window.commentSystem && typeof window.commentSystem.loadEmojis === 'function') {
window.commentSystem.loadEmojis();
}
// Global dispatch for other listeners (e.g. Admin Dashboard)
document.dispatchEvent(new Event('f0ck:emojis_updated'));
} else if (data.type === 'warning') {
console.log(`[SSE] Warning received:`, data.data);
window.f0ckDebug(`[SSE] Warning received:`, data.data);
const warningModal = document.getElementById('warning-modal');
if (warningModal) {
document.getElementById('warning-reason').textContent = data.data.reason;
document.getElementById('warning-id').value = data.data.warning_id;
warningModal.style.display = 'flex';
document.body.classList.add('modal-open');
}
} else if (data.type === 'private_message') {
console.log(`[SSE] Private message received from user ${data.data?.sender_id}`);
window.f0ckDebug(`[SSE] Private message received from user ${data.data?.sender_id}`);
// Haptic feedback for DMs (distinct double-pulse pattern)
if (navigator.vibrate) navigator.vibrate([120, 60, 120]);
// Dispatch event for messages.js thread live-update
@@ -5432,6 +5591,12 @@ class NotificationSystem {
} else if (data.type === 'profile_update') {
const { display_name, user } = data.data;
this.applyDisplayNameUpdate(display_name, user);
// Sync preferences to global session object for client-side gating (like DND)
if (window.f0ckSession && data.data.user_id === window.f0ckSession.id) {
for (const key in data.data) {
if (key !== 'user_id') window.f0ckSession[key] = data.data[key];
}
}
} else if (data.type === 'global_chat') {
document.dispatchEvent(new CustomEvent('f0ck:global_chat', { detail: data.data }));
} else if (data.type === 'global_chat_clear') {
@@ -5505,14 +5670,14 @@ class NotificationSystem {
// If tab is hidden, don't retry now — visibilitychange will restart SSE when visible again
if (document.hidden) {
console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
window.f0ckDebug("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
return;
}
// Exponential backoff, capped at 30s
const delay = Math.min(Math.pow(2, this.retryCount) * 1000, 30000);
if (this.retryCount < this.maxRetries) {
console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
window.f0ckDebug(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
setTimeout(() => this.initSSE(), delay);
this.retryCount++;
} else {
@@ -5618,7 +5783,7 @@ class NotificationSystem {
// Handle "Mark as Read"
if (link.dataset.id && link.classList.contains('unread')) {
console.log(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
window.f0ckDebug(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
// Fire and forget (keepalive ensures it survives navigation)
fetch(`/api/notifications/${link.dataset.id}/read`, {
method: 'POST',
@@ -5664,6 +5829,10 @@ class NotificationSystem {
if (href && href !== '#') {
e.preventDefault();
// Immediately restore scrollability and hide modals for better UX
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
// Close dropdown
if (this.isOpen) this.close();
@@ -5766,7 +5935,7 @@ class NotificationSystem {
if (ids.length === 0) return;
const maxId = Math.max(...ids);
console.log(`[NotificationSystem] Checking for items newer than ${maxId}...`);
window.f0ckDebug(`[NotificationSystem] Checking for items newer than ${maxId}...`);
try {
// Build filters from URL
@@ -5806,7 +5975,7 @@ class NotificationSystem {
const data = await res.json();
if (data.success && data.html) {
console.log(`[NotificationSystem] Loaded new items for grid.`);
window.f0ckDebug(`[NotificationSystem] Loaded new items for grid.`);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(data.html);
@@ -5911,7 +6080,7 @@ class NotificationSystem {
tabNotifs.forEach(n => {
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
if (!existing) {
console.log("[NotificationSystem] Adding new item to history:", n.id);
window.f0ckDebug("[NotificationSystem] Adding new item to history:", n.id);
const html = this.renderHistoryItem(n);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(html);
@@ -5919,7 +6088,7 @@ class NotificationSystem {
node.classList.add('new-item-fade');
historyContainer.prepend(node);
} else {
console.log("[NotificationSystem] Item already exists:", n.id);
window.f0ckDebug("[NotificationSystem] Item already exists:", n.id);
}
});
}
@@ -6177,10 +6346,10 @@ class NotificationSystem {
const idLink = document.querySelector('.id-link');
const currentId = idLink ? parseInt(idLink.innerText) : null;
console.log(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
window.f0ckDebug(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
if (currentId !== parseInt(data.item_id)) {
console.log("[NotificationSystem] Item ID mismatch, ignoring update.");
window.f0ckDebug("[NotificationSystem] Item ID mismatch, ignoring update.");
return;
}
@@ -6192,11 +6361,11 @@ class NotificationSystem {
// DO NOT re-render if the user is currently typing a new tag (has an active input)
if (tagsContainer.querySelector('input')) {
console.log("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
window.f0ckDebug("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
return;
}
console.log("[NotificationSystem] Re-rendering tags for item:", data.item_id);
window.f0ckDebug("[NotificationSystem] Re-rendering tags for item:", data.item_id);
const inner = tagsContainer.querySelector('.tags-inner') || tagsContainer;
@@ -6216,7 +6385,7 @@ class NotificationSystem {
const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator);
const hasSession = !!window.f0ckSession;
console.log(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
window.f0ckDebug(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
const fragment = document.createDocumentFragment();
data.tags.forEach(tag => {
@@ -6256,7 +6425,7 @@ class NotificationSystem {
const favsContainer = document.querySelector('#favs');
if (!favsContainer) return;
console.log("[NotificationSystem] Live Favorite Update for item:", data.item_id);
window.f0ckDebug("[NotificationSystem] Live Favorite Update for item:", data.item_id);
// Sync the heart icon for the current user
const currentUser = window.f0ckSession?.user?.toLowerCase();
@@ -6384,7 +6553,7 @@ class NotificationSystem {
// 3. Handle Activity Feed
const activityContainer = document.getElementById('activity-container');
if (activityContainer) {
console.log("[NotificationSystem] New Activity:", data);
window.f0ckDebug("[NotificationSystem] New Activity:", data);
const html = this.renderActivityItem(data);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(html);
@@ -6965,6 +7134,7 @@ document.addEventListener('DOMContentLoaded', () => {
const intervals = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
{ unit: 'week', seconds: 604800 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
@@ -6975,13 +7145,15 @@ document.addEventListener('DOMContentLoaded', () => {
const count = Math.floor(seconds / interval.seconds);
if (count >= 1) {
try {
// Force fallback for custom/unrecognized locales like 'zange'
if (lang === 'zange') throw new Error('Force fallback');
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
return rtf.format(-count, interval.unit);
} catch (e) {
// Intl not available — fall back to i18n strings
const key = count === 1 ? `timeago_${interval.unit}` : `timeago_${interval.unit}s`;
const tpl = i18n[key] || `{n} ${interval.unit}${count !== 1 ? 's' : ''}`;
const timeStr = tpl.replace('{n}', count);
const timeStr = tpl.replace('{n}', count).replace('{s}', count !== 1 ? 's' : '');
const agoTpl = i18n.timeago_ago || '{t} ago';
return agoTpl.replace('{t}', timeStr);
}
@@ -7307,6 +7479,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
}
const commentBtn = e.target.closest('.report-comment-btn');
@@ -7318,6 +7491,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
}
const userBtn = e.target.closest('.report-user-btn'); // for future
@@ -7329,11 +7503,13 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
}
// Close logic
if (e.target.matches('#report-cancel')) {
reportModal.style.display = 'none';
document.body.classList.remove('modal-open');
}
// Submit logic
@@ -7392,6 +7568,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('warning-reason').textContent = warning.reason;
document.getElementById('warning-id').value = warning.id;
warningModal.style.display = 'flex';
document.body.classList.add('modal-open');
}
}
} catch (e) { console.error('Error fetching warnings', e); }
@@ -7414,6 +7591,7 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
if (data.success) {
document.getElementById('warning-modal').style.display = 'none';
document.body.classList.remove('modal-open');
// Check for more warnings
checkWarnings();
} else {
@@ -8128,6 +8306,7 @@ if (navigator.vibrate) {
const noResults = document.getElementById('metadata-no-results');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
loading.style.display = 'block';
resultsCont.style.display = 'none';
error.style.display = 'none';
@@ -8136,6 +8315,7 @@ if (navigator.vibrate) {
const close = () => {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', onEsc);
window.removeEventListener('pjax:start', onNav);
document.removeEventListener('f0ck:contentLoaded', onContentLoaded);
@@ -8365,4 +8545,9 @@ if (navigator.vibrate) {
}
}
});
// Ensure any navigation event restores the scroll state
window.addEventListener('pjax:start', () => {
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
});
})();

View File

@@ -692,7 +692,7 @@
// firefox mobile check
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
if (isFirefoxMobile) {
console.log("Firefox Mobile detected, disabling Flash Yank script.");
window.f0ckDebug("Firefox Mobile detected, disabling Flash Yank script.");
return;
}

View File

@@ -15,7 +15,7 @@
const MAX_VISIBLE_MSGS = 100;
const RATE_LIMIT_MS = 800;
let isMinimized = localStorage.getItem('f0ck_chat_minimized') === '1';
let isMinimized = localStorage.getItem('f0ck_chat_minimized') !== '0';
let isClosed = localStorage.getItem('f0ck_chat_closed') === '1';
let lastSent = 0;
let customEmojis = null; // name → url
@@ -224,6 +224,13 @@
`</div>` +
`</a>`;
});
// 6d.1 Vocaroo URLs → iframe embed
const vocarooRegex = /https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^\s]*/gi;
html = html.replace(vocarooRegex, (match, vocarooId) => {
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
return `<span class="vocaroo-embed-wrap gchat-embed-audio"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
});
// 6d.5 Same-site item page links → post preview card (resolved async)
// Only catches /digits paths — direct media file URLs are handled by 6a-6c & 6e.
@@ -1133,8 +1140,18 @@
panel.style.top = newY + 'px';
};
const onEnd = () => {
const curY = parseInt(panel.style.top);
localStorage.setItem('f0ck_chat_float_x', parseInt(panel.style.left));
localStorage.setItem('f0ck_chat_float_y', parseInt(panel.style.top));
localStorage.setItem('f0ck_chat_float_y', curY);
if (isMinimized) {
const topBound = getTopBound();
const bottomBound = window.innerHeight;
const distToTop = Math.abs(curY - topBound);
const distToBottom = Math.abs(bottomBound - (curY + 42));
localStorage.setItem('f0ck_chat_anchor_top', distToTop < distToBottom ? '1' : '0');
}
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
@@ -1169,28 +1186,59 @@
// Minimize toggle — in float mode anchor to bottom edge in both directions
function toggleMinimized() {
const willExpand = isMinimized; // about to expand
if (!willExpand && isFloating) {
// About to minimize: capture bottom edge BEFORE shrinking
if (isFloating) {
const r = panel.getBoundingClientRect();
const curBottom = r.top + r.height;
setMinimized(true);
// After CSS applies 42px height, shift top so bottom edge is preserved
requestAnimationFrame(() => {
const newTop = Math.min(window.innerHeight - 42, Math.max(getTopBound(), curBottom - 42));
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
} else {
setMinimized(!isMinimized);
if (willExpand && isFloating) {
// Expanding: shift top UP to maintain bottom edge
const topBound = getTopBound();
const bottomBound = window.innerHeight;
if (!willExpand) {
// About to minimize: decide anchor based on proximity
const distToTop = Math.abs(r.top - topBound);
const distToBottom = Math.abs(bottomBound - (r.top + r.height));
const anchorTop = distToTop < distToBottom;
localStorage.setItem('f0ck_chat_anchor_top', anchorTop ? '1' : '0');
const curTop = r.top;
const curBottom = r.top + r.height;
setMinimized(true);
requestAnimationFrame(() => {
const fullH = panel.getBoundingClientRect().height;
const curTop = parseInt(panel.style.top) || 0;
const newTop = Math.max(getTopBound(), curTop - (fullH - 42));
let newTop;
if (anchorTop) {
// Anchor to top: keep current top
newTop = Math.max(topBound, curTop);
} else {
// Anchor to bottom: keep current bottom (existing behavior)
newTop = Math.min(bottomBound - 42, Math.max(topBound, curBottom - 42));
}
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
} else {
// Expanding: use saved anchor or default to bottom if not set
const wasAnchorTop = localStorage.getItem('f0ck_chat_anchor_top') === '1';
setMinimized(false);
requestAnimationFrame(() => {
const fullH = panel.getBoundingClientRect().height;
const curTop = parseInt(panel.style.top) || 0;
let newTop;
if (wasAnchorTop) {
// Expanding from top anchor: top stays same
newTop = Math.max(topBound, curTop);
} else {
// Expanding from bottom anchor: shift top UP (existing behavior)
newTop = Math.max(topBound, curTop - (fullH - 42));
}
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
}
} else {
setMinimized(!isMinimized);
if (!isMinimized) {
// Normal docked expand
loadHistory();
}
}
}
@@ -1341,10 +1389,10 @@
});
// ── Online users bar ─────────────────────────────────────────────────
function renderOnline(users) {
function renderOnline(users, guestCount = 0) {
const el = document.getElementById('gchat-online');
if (!el) return;
if (!users || users.length === 0) {
if ((!users || users.length === 0) && guestCount === 0) {
el.innerHTML = '';
el.style.display = 'none';
return;
@@ -1361,11 +1409,16 @@
return `<img src="${src}" class="gchat-online-avatar" title="${name}" alt="${name}" loading="lazy" ${colorStyle}>`;
}).join('');
const extraPill = extra > 0 ? `<span class="gchat-online-extra">+${extra}</span>` : '';
const countLabel = `<span class="gchat-online-count">${users.length} online</span>`;
let countText = `${users.length} online`;
if (guestCount > 0 && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
countText += ` (${guestCount} guests)`;
}
const countLabel = `<span class="gchat-online-count">${countText}</span>`;
el.innerHTML = `<div class="gchat-online-inner">${countLabel}<div class="gchat-online-avatars">${avatarHTML}${extraPill}</div></div>`;
}
document.addEventListener('f0ck:global_chat_presence', (e) => {
renderOnline(e.detail?.users || []);
renderOnline(e.detail?.users || [], e.detail?.guestCount || 0);
});
// Event delegation: reply + admin delete buttons inside #gchat-messages

View File

@@ -328,6 +328,13 @@ if (window.__dmLoaded) {
currentOtherId = parseInt(thread.dataset.otherId, 10);
myId = parseInt(thread.dataset.myId, 10);
// Reset state for a fresh load (essential if key just changed or multiple inits ran)
renderedIds.clear();
threadMessages = [];
latestMsgId = 0;
oldestMsgId = null;
threadHasMore = false;
// Update page title to reflect the conversation
const otherName = thread.dataset.otherName || '';
document.title = otherName ? `DM with ${otherName}` : 'Messages';
@@ -601,7 +608,7 @@ if (window.__dmLoaded) {
// 6. Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
if (trimmed.startsWith('>')) {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `<span class="greentext">&gt;${quoteContent}</span>`;
}
@@ -659,6 +666,13 @@ if (window.__dmLoaded) {
html = html.replace(ytEmbedRegex, (match, videoId) => {
return `<span class="yt-embed-wrap"><iframe src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></span>`;
});
// 7.5 Vocaroo embed logic
const vocarooEmbedRegex = /(?:<p>)?\s*<a\s+[^>]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>\s*(?:<\/p>)?/gi;
html = html.replace(vocarooEmbedRegex, (match, vocarooId) => {
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
return `<span class="vocaroo-embed-wrap"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
});
// 8. Same-site video embed logic
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -1298,6 +1312,8 @@ if (window.__dmLoaded) {
localStorage.removeItem(DM_KEY_VERSION);
_privateKey = null; _publicKeyJwk = null;
await showRecoveryModal();
// REFRESH UI after recovery
if (typeof initMessagesPage === 'function') await initMessagesPage();
resolve();
};
@@ -1476,6 +1492,10 @@ if (window.__dmLoaded) {
modal.querySelector('#dm-recover-phrase-btn').onclick = async () => {
modal.style.display = 'none';
await showRecoveryModal();
// REFRESH UI after recovery
if (typeof initMessagesPage === 'function') await initMessagesPage();
// Refresh status label on re-open
const statusEl = modal.querySelector('#dm-key-status');
if (statusEl) statusEl.textContent = hasKey() ? '✅ Key loaded and backed up.' : '❌ No key found.';
@@ -1494,6 +1514,8 @@ if (window.__dmLoaded) {
const status = await loadOrCreateKeyPair();
uploadPublicKey().catch(() => {});
if (status === 'new') await showSeedSetupModal();
// REFRESH UI after regen/setup
if (typeof initMessagesPage === 'function') await initMessagesPage();
} catch (e) {
setMsg(msg, '❌ Error: ' + e.message, 'err');
}
@@ -1595,7 +1617,7 @@ if (window.__dmLoaded) {
const thread = document.getElementById('dm-thread');
if (thread && currentOtherId && parseInt(thread.dataset.otherId) === currentOtherId) {
console.log('[DM] Tab active: catching up on conversation...');
window.f0ckDebug('[DM] Tab active: catching up on conversation...');
appendNewMessages(thread).then(() => {
dmFetch('POST', `/api/dm/read/${currentOtherId}`)
.then(() => refreshDmBadge()) // refreshDmBadge clears title via updateDmBadge(0)
@@ -1644,7 +1666,7 @@ if (window.__dmLoaded) {
const thread = document.getElementById('dm-thread');
if (!thread || !threadMessages.length) return;
console.log('[DM] Emojis ready, re-rendering thread...');
window.f0ckDebug('[DM] Emojis ready, re-rendering thread...');
const isAtBottom = (thread.scrollHeight - thread.scrollTop - thread.clientHeight) < 50;
// Clear and re-render from cache

View File

@@ -3,7 +3,10 @@
* Protects against XSS by stripping disallowed tags and attributes.
*/
class Sanitizer {
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'textarea', 'button', 'input', 'label', 'select', 'option', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
// F-009 Security: Removed form elements (textarea, button, input, label, select, option)
// to prevent phishing via user-generated content (comments, DMs, chat).
// Style attribute is kept for admin-authored MOTD content.
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
static ALLOWED_ATTRS = ['class', 'style', 'src', 'href', 'alt', 'title', 'target', 'width', 'height', 'placeholder', 'readonly', 'disabled', 'value', 'name', 'id', 'type', 'data-parent', 'data-id', 'data-username', 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'points', 'x1', 'y1', 'x2', 'y2', 'd', 'transform', 'rx', 'ry', 'x', 'y', 'offset', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'fill-opacity', 'stroke-opacity', 'preserveaspectratio', 'vector-effect', 'pointer-events', 'allowfullscreen', 'frameborder', 'allow', 'referrerpolicy', 'rel', 'controls', 'loop', 'muted', 'playsinline', 'preload', 'tooltip', 'flow'];
static DISALLOWED_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
@@ -56,9 +59,11 @@ class Sanitizer {
if (this.DISALLOWED_URL_SCHEMES.some(scheme => val.startsWith(scheme))) {
node.removeAttribute(attr.name);
}
// Iframes: only allow YouTube embed URLs
// Iframes: allow YouTube and Vocaroo embed URLs
if (attrName === 'src' && tagName === 'iframe') {
if (!val.startsWith('https://www.youtube.com/embed/')) {
const isYouTube = val.startsWith('https://www.youtube.com/embed/');
const isVocaroo = val.startsWith('https://vocaroo.com/embed/');
if (!isYouTube && !isVocaroo) {
node.removeAttribute(attr.name);
}
}
@@ -71,7 +76,14 @@ class Sanitizer {
const styleParts = attr.value.split(';').filter(p => p.trim().length > 0);
const cleanStyles = styleParts.filter(part => {
const prop = part.split(':')[0].trim().toLowerCase();
return safeStyles.includes(prop);
if (!safeStyles.includes(prop)) return false;
// F-009 Security: Strip url() from background/background-image
// to prevent CSS-based tracking (e.g. background-image: url(https://evil.com/track))
const val = part.split(':').slice(1).join(':').trim().toLowerCase();
if ((prop === 'background-image' || prop === 'background') && /url\s*\(/i.test(val)) {
return false;
}
return true;
});
if (cleanStyles.length > 0) {
node.setAttribute(attr.name, cleanStyles.join('; '));

View File

@@ -335,6 +335,7 @@
// Close popup if click outside
document.addEventListener('click', e => {
if (!document.body.classList.contains('scroller-active')) return;
if (!volumePopup.contains(e.target) && e.target !== muteBtn && !muteBtn.contains(e.target)) {
volumePopup.classList.remove('open');
}
@@ -369,6 +370,11 @@
closePanel(filterPanel, filterBackdrop);
closePanel(commentsPanel, commentsBackdrop);
closePanel(settingsPanel, settingsBackdrop);
if (typeof closeTagBar === 'function') closeTagBar();
if (typeof closeSharePanel === 'function') closeSharePanel();
if (typeof closeChanPanel === 'function') closeChanPanel();
const active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) active.blur();
}
function addSwipeClose(panel, backdrop) {
let startY = 0;
@@ -690,10 +696,22 @@
});
}
// Spoiler/blur reveal — delegated click on the comments list
// Spoiler/blur reveal & context links — delegated click on the comments list
commentsList.addEventListener('click', e => {
const sp = e.target.closest('.scroller-spoiler, .scroller-blur');
if (sp) sp.classList.toggle('revealed');
const contextLink = e.target.closest('.comment-context-link');
if (contextLink) {
e.preventDefault();
const id = contextLink.dataset.id;
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('highlight-comment');
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
}
}
});
// ── Slide activation ──────────────────────────────────────────────────────
@@ -1593,6 +1611,7 @@
is_video: isVideo,
is_image: isImage,
is_audio: false,
comment_count: p.replies || 0,
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
};
@@ -1618,6 +1637,7 @@
item.username = m.username;
item.display_name = m.display_name;
item.avatar = m.avatar;
if (m.comment_count != null) item.comment_count = m.comment_count;
if (m.rating_class) { item.rating_class = m.rating_class; item.rating_label = m.rating_label; }
}
@@ -1931,10 +1951,38 @@
indicator.style.display = 'flex';
}
if (commentInput) {
const quote = `>>${commentId} `;
const start = commentInput.selectionStart;
const end = commentInput.selectionEnd;
const val = commentInput.value;
commentInput.value = val.substring(0, start) + quote + val.substring(end);
commentInput.focus();
commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
commentInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function quoteComment(id, username) {
if (!commentInput) return;
const commentEl = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
if (!commentEl) return;
const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return;
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
const lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
const start = commentInput.selectionStart;
const end = commentInput.selectionEnd;
const val = commentInput.value;
commentInput.value = val.substring(0, start) + quote + val.substring(end);
commentInput.focus();
commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
commentInput.dispatchEvent(new Event('input', { bubbles: true }));
}
function clearReply() {
replyToCommentId = null;
replyToUsername = null;
@@ -1956,19 +2004,28 @@
<img class="comment-avatar" src="${esc(av)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
<div class="comment-body">
<div class="comment-username" style="${nc}">${esc(uname)}</div>
<div class="comment-content">${renderCommentContent(c.content || '')}</div>
<div class="comment-content" data-raw="${esc(c.content || '')}">${renderCommentContent(c.content || '')}</div>
<div class="comment-meta">
<span class="comment-time">${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')}</span>
${canReply ? `<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>` : ''}
${canReply ? `
<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>
<button class="comment-quote-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.quote || 'Quote'}</button>
` : ''}
</div>
</div>`;
// Wire reply button
// Wire buttons
const replyBtn = el.querySelector('.comment-reply-btn');
if (replyBtn) {
replyBtn.addEventListener('click', () => {
setReplyTo(replyBtn.dataset.id, replyBtn.dataset.user);
});
}
const quoteBtn = el.querySelector('.comment-quote-btn');
if (quoteBtn) {
quoteBtn.addEventListener('click', () => {
quoteComment(quoteBtn.dataset.id, quoteBtn.dataset.user);
});
}
return el;
}
@@ -2009,6 +2066,7 @@
const tagBarClose = document.getElementById('tag-bar-close-btn');
function openTagBar(itemId) {
closeAllPanels();
tagBarItemId = itemId;
if (!tagBar) return;
tagBar.classList.add('open');
@@ -2022,11 +2080,9 @@
closeSugg();
tagBarItemId = null;
const inp = document.getElementById('scroll-tag-input');
if (inp) inp.value = '';
if (inp) { inp.value = ''; inp.blur(); }
}
if (tagBarClose) tagBarClose.addEventListener('click', closeTagBar);
// Close on Escape key
document.addEventListener('keydown', e => { if (e.key === 'Escape' && tagBar?.classList.contains('open')) closeTagBar(); });
// ── Share panel ────────────────────────────────────────────────────────────
const sharePanel = document.getElementById('share-panel');
@@ -2263,11 +2319,13 @@
);
// Escape HTML first, then process line by line
const escaped = esc(text);
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const escaped = esc(normalized);
const lines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
// Greentext / quote: lines starting with >
if (trimmed.startsWith('&gt;')) {
// Exclude only the numeric context links (>>ID) so they can be handled as interactive links.
if (trimmed.startsWith('&gt;') && !trimmed.match(/^&gt;&gt;\d+/)) {
const after = line.substring(line.indexOf('&gt;') + 4);
const withEmoji = after.replace(/:([a-z0-9_]+):/g, (m, name) => {
const url = customEmojis[name];
@@ -2289,9 +2347,19 @@
const url = customEmojis[name];
return url ? `<img src="${esc(url)}" alt=":${esc(name)}:" title=":${esc(name)}:" style="height:1.8em;vertical-align:middle;display:inline-block;margin:0 2px">` : m;
});
// 3. Replace >>ID patterns with context links
out = out.replace(/(?<!\w)&gt;&gt;(\d+)/g, (match, id) => {
return `<a href="#c${id}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
});
return out;
});
let html = lines.join('<br>');
let html = lines.map((line, i) => {
if (i === lines.length - 1) return line;
if (line.includes('scroller-greentext') || line.includes('display:block')) return line;
return line + '<br>';
}).join('');
// [spoiler]…[/spoiler] — iterative to handle nesting
const spoilerRe = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
let prev, n = 0;
@@ -2783,12 +2851,12 @@
chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`)
.then(r => r.json())
.then(data => {
console.log('[CHAN] Find result:', data);
window.f0ckDebug('[CHAN] Find result:', data);
if (data.success && data.tid) {
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
applied.order = 'oldest';
unlock4chan();
console.log('[CHAN] Set externalUrl:', applied.externalUrl);
window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl);
}
})
.catch(err => { console.error('[CHAN] Find error:', err); });
@@ -2887,11 +2955,13 @@
// ── Keyboard ──────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (!document.body.classList.contains('scroller-active')) return;
const active = document.activeElement;
const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
// 'c' toggles comments — only when NOT typing (so it never fires inside the comment input)
if ((e.key === 'c' || e.key === 'C') && !isTyping && !tagBar?.classList.contains('open')) {
e.preventDefault();
if (commentsPanel.classList.contains('open')) closeAllPanels();
else if (currentSlide) openComments(currentSlide.dataset.localId || currentSlide.dataset.id);
return;
@@ -2915,10 +2985,10 @@
const idx = currentSlide ? slides.indexOf(currentSlide) : 0;
if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); const n = slides[idx + 1]; if (n) n.scrollIntoView({ behavior: 'smooth' }); }
else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); const p = slides[idx - 1]; if (p) p.scrollIntoView({ behavior: 'smooth' }); }
else if (e.key === 'm') { isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
else if (e.key === 'm') { e.preventDefault(); isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
else if (e.key === ' ') { e.preventDefault(); if (currentMedia) currentMedia.paused ? currentMedia.play().catch(() => {}) : currentMedia.pause(); }
else if (e.key === 'f' || e.key === 'F') { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
else if (e.key === 'g' || e.key === 'G') { if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
e.stopImmediatePropagation(); // prevent f0ckm.js global 'r' random shortcut from firing
@@ -2982,7 +3052,7 @@
})
.catch(() => {});
}
else if (e.key === 'l' || e.key === 'L') { if (currentSlide) toggleFav(currentSlide); }
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss
else if (e.key === 'Escape') { window.location.href = '/'; }
@@ -3000,6 +3070,7 @@
let fourCount = 0;
let fourTimer = null;
document.addEventListener('keydown', (e) => {
if (!document.body.classList.contains('scroller-active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === '4') {
fourCount++;
@@ -3407,6 +3478,7 @@
// Close on outside click
document.addEventListener('click', (e) => {
if (!document.body.classList.contains('scroller-active')) return;
if (sNotifOpen && !sNotifDropdown.contains(e.target) && !sNotifBtn.contains(e.target)) {
sNotifOpen = false;
sNotifDropdown.classList.remove('visible');

View File

@@ -612,6 +612,97 @@
});
}
// Comment Display Mode Toggle
const commentDisplayModeSelect = document.getElementById('comment_display_mode_select');
if (commentDisplayModeSelect) {
commentDisplayModeSelect.addEventListener('change', async () => {
const mode = parseInt(commentDisplayModeSelect.value, 10);
try {
const res = await fetch('/api/v2/settings/comment_display_mode', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ mode })
});
const data = await res.json();
if (data.success) {
showStatus('Comment display mode updated!', 'success');
if (window.f0ckSession) window.f0ckSession.comment_display_mode = mode;
} else {
alert(data.msg || 'Error saving preference');
}
} catch (err) {
console.error(err);
alert('Failed to save preference');
}
});
}
// Alternative Infobox Toggle (legacy layout only)
const alternativeInfoboxToggle = document.getElementById('alternative_infobox_toggle');
if (alternativeInfoboxToggle) {
alternativeInfoboxToggle.addEventListener('change', async () => {
const use_alternative_infobox = alternativeInfoboxToggle.checked;
try {
const res = await fetch('/api/v2/settings/alternative_infobox', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ use_alternative_infobox })
});
const data = await res.json();
if (data.success) {
showStatus('Infobox preference updated!', 'success');
if (window.f0ckSession) window.f0ckSession.use_alternative_infobox = use_alternative_infobox;
} else {
alert(data.msg || 'Error saving preference');
alternativeInfoboxToggle.checked = !use_alternative_infobox;
}
} catch (err) {
console.error(err);
alert('Failed to save infobox preference');
alternativeInfoboxToggle.checked = !use_alternative_infobox;
}
});
}
// Notification Preferences Toggles
const setupPreferenceToggle = (id, sessionKey) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', async () => {
const enabled = el.checked;
try {
const res = await fetch('/api/v2/settings/notifications', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ key: sessionKey, value: enabled })
});
const data = await res.json();
if (data.success) {
if (window.f0ckSession) window.f0ckSession[sessionKey] = enabled;
} else {
alert(data.msg || 'Error saving preference');
el.checked = !enabled;
}
} catch (err) {
console.error(err);
el.checked = !enabled;
}
});
};
setupPreferenceToggle('chk-receive-system-notifications', 'receive_system_notifications');
setupPreferenceToggle('chk-receive-user-notifications', 'receive_user_notifications');
setupPreferenceToggle('chk-do-not-disturb', 'do_not_disturb');
const wheelToggle = document.getElementById('wheel_nav_toggle');
if (wheelToggle) {
wheelToggle.checked = localStorage.getItem('wheelNavEnabled') === 'true';
@@ -1255,26 +1346,16 @@
}
// ==== Ruffle (Flash) Settings ====
const ruffleVolumeInput = document.getElementById('ruffle_volume_input');
const ruffleVolumeVal = document.getElementById('ruffle_volume_val');
const ruffleBackToggle = document.getElementById('ruffle_background_toggle');
const ruffleSaveBtn = document.getElementById('btn-save-ruffle-settings');
const ruffleStatus = document.getElementById('ruffle-settings-status');
if (ruffleVolumeInput && ruffleVolumeVal) {
ruffleVolumeInput.addEventListener('input', () => {
ruffleVolumeVal.textContent = Math.round(ruffleVolumeInput.value * 100) + '%';
});
}
if (ruffleSaveBtn) {
ruffleSaveBtn.addEventListener('click', async () => {
const ruffle_volume = parseFloat(ruffleVolumeInput.value);
if (ruffleBackToggle) {
ruffleBackToggle.addEventListener('change', async () => {
const ruffle_background = ruffleBackToggle.checked;
ruffleSaveBtn.disabled = true;
ruffleSaveBtn.textContent = i18n.saving || 'Saving...';
try {
const res = await fetch('/api/v2/settings/ruffle', {
method: 'PUT',
@@ -1282,13 +1363,12 @@
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ ruffle_volume, ruffle_background })
body: JSON.stringify({ ruffle_background })
});
const data = await res.json();
if (data.success) {
showAccountStatus(ruffleStatus, 'Flash settings updated correctly!', 'success');
showAccountStatus(ruffleStatus, 'Flash settings updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.ruffle_volume = ruffle_volume;
window.f0ckSession.ruffle_background = ruffle_background;
// Apply to the active Ruffle player if it exists so user doesn't need to refresh
@@ -1296,7 +1376,6 @@
if (ruffleContainer) {
const player = ruffleContainer.querySelector('ruffle-player') || ruffleContainer.querySelector('ruffle-object');
if (player) {
player.volume = ruffle_volume;
// Ruffle doesn't dynamically toggle pageVisibility well without recreation,
// but we can update the config for subsequent initializations
if (window.RufflePlayer && window.RufflePlayer.config) {
@@ -1308,13 +1387,12 @@
}
} else {
showAccountStatus(ruffleStatus, data.msg || 'Failed to update Flash settings', 'error');
ruffleBackToggle.checked = !ruffle_background; // Revert on failure
}
} catch (err) {
console.error(err);
showAccountStatus(ruffleStatus, 'Request failed', 'error');
} finally {
ruffleSaveBtn.disabled = false;
ruffleSaveBtn.textContent = 'Save Flash Settings';
ruffleBackToggle.checked = !ruffle_background; // Revert on error
}
});
}

View File

@@ -37,7 +37,56 @@
return div.innerHTML;
};
const renderCommentContent = (content) => {
const ytOembedCache = new Map(); // videoId -> meta object
const ytOembedPending = new Map(); // videoId -> Promise
const fetchSidebarYoutubeTitles = async (container) => {
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
if (links.length === 0) return;
for (const link of links) {
const videoId = link.dataset.ytId;
if (!videoId) continue;
const titleSpan = link.querySelector('.yt-title');
if (!titleSpan || titleSpan.dataset.loaded === 'true') continue;
let meta = ytOembedCache.get(videoId);
if (!meta) {
if (ytOembedPending.has(videoId)) {
meta = await ytOembedPending.get(videoId);
} else {
const promise = (async () => {
try {
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
if (r.ok) {
const data = await r.json();
if (data.success && data.meta) {
ytOembedCache.set(videoId, data.meta);
return data.meta;
}
}
} catch (e) {}
return null;
})();
ytOembedPending.set(videoId, promise);
meta = await promise;
ytOembedPending.delete(videoId);
}
}
if (meta && meta.title) {
titleSpan.textContent = meta.title;
} else {
// If title fails, just leave it blank or use a generic label
titleSpan.textContent = 'YouTube Video';
}
titleSpan.dataset.loaded = 'true';
}
};
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Anti-recursion / Performance safeguard for extremely long comments
@@ -149,7 +198,7 @@
// Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
if (trimmed.startsWith('>')) {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
@@ -166,10 +215,19 @@
// Perform replacements on the single line
let processedLine = line;
// Handle Mentions
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
const user = g1 || g2;
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
});
// Handle Comment Context Links (>>ID)
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
});
processedLine = processedLine.replace(imageRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
@@ -196,10 +254,25 @@
// YouTube label replacement: show icon + labeled link
md = md.replace(
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
(match) => {
(match, videoId) => {
const hrefMatch = match.match(/href="([^"]+)"/i);
const href = hrefMatch ? hrefMatch[1] : '#';
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-brands fa-youtube"></i></a>`;
const ytHref = hrefMatch ? hrefMatch[1] : '#';
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : ytHref);
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
return `<a href="${targetHref}"${externalAttr} class="sidebar-video-link" data-yt-id="${videoId}"><i class="fa-brands fa-youtube"></i> <span class="yt-title"></span></a>`;
}
);
// Vocaroo label replacement
md = md.replace(
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
(match, vocarooId) => {
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
const hrefMatch = match.match(/href="([^"]+)"/i);
const vocaHref = hrefMatch ? hrefMatch[1] : '#';
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : vocaHref);
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
return `<a href="${targetHref}"${externalAttr} class="sidebar-video-link"><i class="fa-solid fa-microphone"></i> <span>Vocaroo Audio</span></a>`;
}
);
@@ -258,6 +331,10 @@
return codeBlocks[index] || '';
});
if (window.Sanitizer && typeof window.Sanitizer.clean === 'function') {
md = window.Sanitizer.clean(md);
}
return md;
} catch (e) {
return content;
@@ -269,7 +346,7 @@
const renderActivityItem = (c) => {
const rawContent = c.content || c.body || '';
const displayContent = renderCommentContent(rawContent);
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -277,6 +354,7 @@
avatarSrc = `/a/${c.avatar_file}`;
} else if (c.avatar) {
avatarSrc = `/t/${c.avatar}.webp`;
if (window.applyThumbCacheBust) avatarSrc = window.applyThumbCacheBust(avatarSrc);
}
const timeStr = c.created_at
@@ -287,7 +365,9 @@
let itemPreview = '';
if (c.item_id) {
let mediaHtml = '';
mediaHtml = `<img src="/t/${c.item_id}.webp" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
let thumbUrl = `/t/${c.item_id}.webp`;
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
itemPreview = `
<div class="item-preview">
@@ -308,7 +388,7 @@
</div>
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
</div>
<div class="comment-content" style="font-size: 0.85em; line-height: 1.3;"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
${itemPreview}
</div>
</div>`;
@@ -361,16 +441,13 @@
html += renderActivityItem(c);
});
if (window.Sanitizer) {
container.innerHTML = window.Sanitizer.clean(html);
} else {
container.innerHTML = html;
}
container.innerHTML = html;
// Re-append IO sentinel so the scroll observer keeps working after re-renders
if (ioSentinel) {
container.appendChild(ioSentinel);
}
checkOverflow();
fetchSidebarYoutubeTitles(container);
return true;
};
@@ -462,17 +539,14 @@
newComments.forEach(c => { html += renderActivityItem(c); });
if (html) {
const temp = document.createElement('div');
if (window.Sanitizer) {
temp.innerHTML = window.Sanitizer.clean(html);
} else {
temp.innerHTML = html;
}
temp.innerHTML = html;
while (temp.firstElementChild) {
container.appendChild(temp.firstElementChild);
}
// Keep the IO sentinel at the very end so it triggers on the next scroll
if (ioSentinel) container.appendChild(ioSentinel);
checkOverflow();
fetchSidebarYoutubeTitles(container);
}
} else {
hasMore = false;
@@ -496,7 +570,7 @@
// 1. Deduplicate: check if this comment ID is already in the cache
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
console.log("Sidebar Activity: Duplicate comment ignored", data.id);
window.f0ckDebug("Sidebar Activity: Duplicate comment ignored", data.id);
return;
}
@@ -512,16 +586,13 @@
if (container) {
const html = renderActivityItem(newItem);
const temp = document.createElement('div');
if (window.Sanitizer) {
temp.innerHTML = window.Sanitizer.clean(html);
} else {
temp.innerHTML = html;
}
const node = temp.firstElementChild;
if (node) {
node.classList.add('new-item-fade');
container.prepend(node);
checkOverflow();
fetchSidebarYoutubeTitles(container);
}
}
};
@@ -533,7 +604,7 @@
// Listen for live activity from f0ckm.js
document.addEventListener('f0ck:activityReceived', (e) => {
console.log("Sidebar Activity: Live update received", e.detail);
window.f0ckDebug("Sidebar Activity: Live update received", e.detail);
handleNewActivity(e.detail);
});
@@ -555,18 +626,20 @@
if (el) {
const inner = el.querySelector('.comment-content-inner');
if (inner) {
inner.innerHTML = renderCommentContent(data.content);
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
inner.innerHTML = renderCommentContent(data.content, data.comment_id, comment ? comment.item_id : null);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
checkOverflow();
fetchSidebarYoutubeTitles(el);
}
}
}
};
window.addEventListener('f0ck:comment_edited', (e) => {
console.log("Sidebar Activity: Live edit received", e.detail);
window.f0ckDebug("Sidebar Activity: Live edit received", e.detail);
handleLiveEdit(e.detail);
});
@@ -578,7 +651,7 @@
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
lastBoundMode = currentMode;
console.log("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
window.f0ckDebug("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
if (modeChanged) {
window._sidebarActivityCache = [];
@@ -600,7 +673,7 @@
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
document.addEventListener('f0ck:modeChanged', (e) => {
console.log("Sidebar Activity: Mode change detected", e.detail.mode);
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
lastBoundMode = e.detail.mode;
window._sidebarActivityCache = [];
currentPage = 1;
@@ -610,7 +683,7 @@
// When the current user posts a comment, silently refresh sidebar to show it
document.addEventListener('f0ck:commentPosted', () => {
console.log("Sidebar Activity: Own comment posted, refreshing...");
window.f0ckDebug("Sidebar Activity: Own comment posted, refreshing...");
loadActivity(true);
});

View File

@@ -122,6 +122,17 @@ window.F0ckUpload = class {
try {
const res = JSON.parse(xhr.responseText);
if (res.success) {
if (res.itemid) {
try {
const ts = Date.now();
const bustedStr = localStorage.getItem('bustedThumbs');
const busted = bustedStr ? JSON.parse(bustedStr) : {};
busted[res.itemid] = ts;
const keys = Object.keys(busted);
if (keys.length > 50) delete busted[keys[0]];
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
} catch(e) {}
}
this.onComplete(res);
resolve(res);
} else {

View File

@@ -40,6 +40,7 @@ window.initUploadForm = (selector) => {
const dragModal = form.closest('#upload-drag-modal');
if (dragModal) {
dragModal.classList.remove('show');
document.body.classList.remove('modal-open');
if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
form._f0ckUploader.reset();
}
@@ -59,8 +60,7 @@ window.initUploadForm = (selector) => {
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
// Dynamically get min tags requirement from DOM
const minTagsRaw = tagCount?.textContent.match(/\/(\d+)/);
const minTags = minTagsRaw ? parseInt(minTagsRaw[1]) : 3;
const minTags = parseInt(form.getAttribute('data-min-tags') || '3');
let tags = [];
let autoTags = []; // Track tags suggested from metadata
@@ -465,8 +465,12 @@ window.initUploadForm = (selector) => {
}
if (tagCount) {
tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)';
tagCount.classList.toggle('valid', tags.length >= minTags);
if (minTags > 0) {
tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)';
tagCount.classList.toggle('valid', tags.length >= minTags);
} else {
tagCount.style.display = 'none';
}
}
};
@@ -567,6 +571,10 @@ window.initUploadForm = (selector) => {
previewElem = document.createElement('div');
previewElem.className = 'generic-file-icon swf-preview-icon';
previewElem.innerHTML = '<span style="font-size:2.5em;">⚡</span><br><span style="font-size:0.85em;letter-spacing:0.1em;color:#e040fb;font-weight:bold;">SWF</span>';
} else if (file.type === 'application/pdf' || fileExt === 'pdf') {
previewElem = document.createElement('div');
previewElem.className = 'generic-file-icon pdf-preview-icon';
previewElem.innerHTML = '<span style="font-size:2.5em;"><i class="fa-solid fa-file-pdf"></i></span><br><span style="font-size:0.85em;letter-spacing:0.1em;color:#ef5350;font-weight:bold;"></span>';
} else {
previewElem = document.createElement('div');
previewElem.className = 'generic-file-icon';
@@ -951,7 +959,7 @@ window.initUploadForm = (selector) => {
</div>
`;
}
console.log('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
window.f0ckDebug('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
if (tagSuggestions) {
tagSuggestions.innerHTML = html;
tagSuggestions.style.display = 'block';
@@ -1087,11 +1095,26 @@ window.initUploadForm = (selector) => {
}
if (dragModal) dragModal.classList.remove('show');
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
form._f0ckUploader.reset();
if (!dragModal) {
statusDiv.innerHTML = '✓ ' + data.msg;
statusDiv.className = 'upload-status success';
}
if (data.itemid) {
try {
const ts = Date.now();
const bustedStr = localStorage.getItem('bustedThumbs');
const busted = bustedStr ? JSON.parse(bustedStr) : {};
busted[data.itemid] = ts;
const keys = Object.keys(busted);
if (keys.length > 50) delete busted[keys[0]];
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
} catch(e) {}
}
if (data.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval, please be patient', 'info');
}
@@ -1190,6 +1213,19 @@ window.initUploadForm = (selector) => {
statusDiv.innerHTML = '✓ ' + res.msg;
statusDiv.className = 'upload-status success';
}
if (res.itemid) {
try {
const ts = Date.now();
const bustedStr = localStorage.getItem('bustedThumbs');
const busted = bustedStr ? JSON.parse(bustedStr) : {};
busted[res.itemid] = ts;
const keys = Object.keys(busted);
if (keys.length > 50) delete busted[keys[0]];
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
} catch(e) {}
}
if (res.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval', 'info');
}

View File

@@ -28,7 +28,7 @@ class UserCommentSystem {
if (el && this.container.contains(el)) {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
contentEl.innerHTML = this.renderCommentContent(data.content);
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
@@ -77,7 +77,7 @@ class UserCommentSystem {
// Check if this instance is still active
if (!document.body.contains(this.container)) return;
console.log('Mode changed, reloading comments...');
window.f0ckDebug('Mode changed, reloading comments...');
this.container.innerHTML = '';
this.page = 1;
this.finished = false;
@@ -108,7 +108,7 @@ class UserCommentSystem {
this.userColor = json.user.username_color;
}
json.comments.forEach(c => {
console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
window.f0ckDebug('Raw Comment Content (ID ' + c.id + '):', c.content);
const html = this.renderComment(c);
this.container.insertAdjacentHTML('beforeend', html);
});
@@ -134,7 +134,7 @@ class UserCommentSystem {
return match;
}
renderCommentContent(content) {
renderCommentContent(content, itemId = null) {
if (!content) return '';
// Anti-recursion / Performance safeguard for extremely long comments
@@ -194,7 +194,7 @@ class UserCommentSystem {
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
if (trimmed.startsWith('>')) {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `<span class="greentext">&gt;${quoteContent}</span>`;
}
@@ -204,6 +204,13 @@ class UserCommentSystem {
if (!line.trim()) return '&nbsp;';
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
// Handle Comment Context Links (>>ID)
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
});
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
@@ -241,6 +248,10 @@ class UserCommentSystem {
iterations++;
} while (html !== prevMd && iterations < 10);
if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
html = Sanitizer.clean(html);
}
return html;
} catch (e) {
console.error('UserCommentSystem Markdown Render Error:', e);
@@ -251,7 +262,7 @@ class UserCommentSystem {
renderComment(c) {
const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content);
const content = this.renderCommentContent(c.content, c.item_id);
// Replicating the structure of comments.js but adapting for the list view
// We add a header indicating which item this comment belongs to

View File

@@ -96,7 +96,7 @@ class v0ck {
if (["video", "audio"].includes(tagName)) {
const parent = elem.parentElement;
if (parent.querySelector('.v0ck_player_controls')) {
console.log("[v0ck] Player controls already exist, skipping injection and init");
window.f0ckDebug("[v0ck] Player controls already exist, skipping injection and init");
return elem; // Return the video element as the constructor result
} else {
parent.classList.add("v0ck", "paused");
@@ -123,7 +123,7 @@ class v0ck {
// Use absolute path for reliable asset loading
const size = elem.getAttribute('data-size');
elem.insertAdjacentHTML("afterend", tpl_player(`/s/img/v0ck.svg`, size));
console.log("[v0ck] Player initialized for", tagName);
window.f0ckDebug("[v0ck] Player initialized for", tagName);
}
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
@@ -430,8 +430,12 @@ class v0ck {
});
}
// Initialize switch state (defaults to ON if not explicitly 'false')
if (toggleBgSwitch && localStorage.getItem('background') !== 'false') {
// Initialize switch state
const bgEnabled = (window.f0ckSession && window.f0ckSession.show_background !== undefined)
? !!window.f0ckSession.show_background
: (localStorage.getItem('background') !== 'false');
if (toggleBgSwitch && bgEnabled) {
toggleBgSwitch.classList.add('active');
}