${counter}
${attachBtn}
${parentId ? `` : ''}
`;
}
setupHoverPreviews() {
if (CommentSystem.hoverPreviewsAttached) return;
CommentSystem.hoverPreviewsAttached = true;
// Hover for Comment Context Links (>>ID) - Global delegation for nested previews (inception)
this.mouseCurrentLevel = -1;
this.currentHoverLink = null;
document.addEventListener('mouseover', (e) => {
const contextLink = e.target.closest('.comment-context-link');
const popup = e.target.closest('.comment-preview-popup');
if (contextLink || popup) {
if (this.previewCloseTimer) {
clearTimeout(this.previewCloseTimer);
this.previewCloseTimer = null;
}
}
if (contextLink) {
// Ignore mouseover for previews on mobile touch devices to prevent tap-to-preview
// We handle mobile previews via the touchstart timer instead.
if (window.matchMedia('(pointer: coarse)').matches) return;
if (this.currentHoverLink === contextLink) return;
this.currentHoverLink = contextLink;
if (this.previewOpenTimer) clearTimeout(this.previewOpenTimer);
this.previewOpenTimer = setTimeout(() => {
this.showCommentPreview(contextLink, e);
}, 150); // 150ms dwell time to prevent flickering while moving mouse
} else {
if (this.previewOpenTimer) {
clearTimeout(this.previewOpenTimer);
this.previewOpenTimer = null;
}
this.currentHoverLink = null;
const level = popup ? parseInt(popup.dataset.level || 0) : -1;
// If we move back to a parent level or blank area of a popup, close its children
// but use a delay so the user can reach the child popup if they are moving towards it.
if (popup && !contextLink) {
this.previewCloseTimer = setTimeout(() => {
this.closePreviewsAboveLevel(level);
}, 400);
}
this.mouseCurrentLevel = level;
}
});
document.addEventListener('mouseout', (e) => {
const contextLink = e.target.closest('.comment-context-link');
const popup = e.target.closest('.comment-preview-popup');
if (contextLink) {
if (this.previewOpenTimer) {
clearTimeout(this.previewOpenTimer);
this.previewOpenTimer = null;
}
this.currentHoverLink = null;
}
if (contextLink || popup) {
if (this.previewCloseTimer) clearTimeout(this.previewCloseTimer);
this.previewCloseTimer = setTimeout(() => {
this.closePreviewsAboveLevel(-1);
this.mouseCurrentLevel = -1;
}, 400);
}
});
// Mobile Touch Support: Touch-and-hold to preview
let touchPreviewTimer = null;
document.addEventListener('touchstart', (e) => {
const contextLink = e.target.closest('.comment-context-link');
if (contextLink) {
if (touchPreviewTimer) clearTimeout(touchPreviewTimer);
touchPreviewTimer = setTimeout(() => {
this.showCommentPreview(contextLink, e);
}, 150); // 150ms hold to trigger preview on mobile
}
}, { passive: true });
document.addEventListener('touchmove', () => {
if (touchPreviewTimer) {
clearTimeout(touchPreviewTimer);
touchPreviewTimer = null;
}
}, { passive: true });
document.addEventListener('touchend', () => {
if (touchPreviewTimer) {
clearTimeout(touchPreviewTimer);
touchPreviewTimer = null;
}
}, { passive: true });
// Global click listener to close popups (useful for mobile dismissal)
document.addEventListener('click', (e) => {
const isLink = e.target.closest('.comment-context-link');
const isPopup = e.target.closest('.comment-preview-popup');
if (!isLink && !isPopup) {
this.closePreviewsAboveLevel(-1);
}
});
}
setupDelegatedEvents() {
_f0ckDebug('[DEBUG] Setting up delegated events for container:', this.container);
if (!this.container) return;
// Ctrl+Enter to submit comment
this.container.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
const textarea = e.target.closest('textarea');
if (!textarea) return;
const wrap = textarea.closest('.comment-input');
if (!wrap) return;
const submitBtn = wrap.querySelector('.submit-comment');
if (submitBtn) submitBtn.click();
} else if (e.key === 'Escape') {
const textarea = e.target.closest('textarea');
if (textarea) textarea.blur();
}
});
// Live character counter (only active when comment_max_length is set)
this.container.addEventListener('input', (e) => {
const textarea = e.target.closest('.comment-input textarea');
if (!textarea) return;
const counter = textarea.closest('.comment-input')?.querySelector('.char-counter');
if (!counter) return;
const max = parseInt(counter.dataset.max, 10);
// Exclude quoted lines (starting with '>') from the count —
// quoted context is capped at 200 chars and shouldn't eat into the user's limit.
const nonQuotedLen = textarea.value
.split('\n')
.filter(line => !line.startsWith('>'))
.join('\n')
.length;
counter.textContent = `${nonQuotedLen} / ${max}`;
counter.classList.toggle('near-limit', nonQuotedLen >= max * 0.9);
counter.classList.toggle('at-limit', nonQuotedLen >= max);
});
// Single Change Listener for Sort
this.container.addEventListener('change', (e) => {
if (e.target.id === 'comment-sort') {
this.sort = e.target.value;
this.loadComments();
}
});
// Single Click Listener for Everything
this.container.addEventListener('change', async (e) => {
if (!e.target.matches('.comment-file-input')) return;
const fileInput = e.target;
const wrap = fileInput.closest('.comment-input');
if (!wrap) return;
const textarea = wrap.querySelector('textarea');
if (!textarea) return;
const previewArea = wrap.querySelector('.comment-file-preview');
const session = window.f0ckSession || {};
const maxSize = session.fileupload_comments_size || (10 * 1024 * 1024);
const i18n = window.f0ckI18n || {};
const removeLabel = i18n.remove_file || 'Remove file';
const maxAttachments = session.fileupload_comments_max || 5;
const currentCount = previewArea ? previewArea.querySelectorAll('.cf-preview-item').length : 0;
const slotsLeft = maxAttachments - currentCount;
if (fileInput.files.length > slotsLeft) {
if (window.flashMessage) window.flashMessage(
`Maximum ${maxAttachments} attachments per comment exceeded`,
3000, 'error'
);
fileInput.value = '';
return;
}
for (const file of fileInput.files) {
if (file.size > maxSize) {
if (window.flashMessage) window.flashMessage((i18n.file_too_large || 'File too large') + `: ${file.name}`, 3000, 'error');
continue;
}
const fd = new FormData();
fd.append('file', file);
const csrf = session.csrf_token || '';
const uploadingText = i18n.uploading_file || 'Uploading...';
const submitBtn = wrap.querySelector('.submit-comment');
// Track pending uploads
wrap._pendingUploads = (wrap._pendingUploads || 0) + 1;
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.classList.add('uploading');
}
// Insert placeholder at cursor position
const cursorPos = textarea.selectionStart;
const before = textarea.value.substring(0, cursorPos);
const after = textarea.value.substring(textarea.selectionEnd);
const placeholder = `[${uploadingText} ${file.name}]`;
const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
textarea.value = before + sep + placeholder + after;
// Show uploading preview
let previewItem = null;
if (previewArea) {
previewItem = document.createElement('div');
previewItem.className = 'cf-preview-item cf-uploading';
const spinner = document.createElement('i');
spinner.className = 'fa-solid fa-spinner fa-spin';
previewItem.appendChild(spinner);
const nameEl = document.createElement('span');
nameEl.className = 'cf-filename';
nameEl.textContent = file.name;
previewItem.appendChild(nameEl);
previewArea.appendChild(previewItem);
}
try {
const res = await fetch('/api/v2/comments/upload', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf },
body: fd
});
const json = await res.json();
if (json.success && json.files && json.files.length > 0) {
const fileData = json.files[0];
const url = `/c/${fileData.dest}`;
textarea.value = textarea.value.replace(placeholder, url);
// Update preview with actual thumbnail
if (previewItem) {
previewItem.classList.remove('cf-uploading');
previewItem.dataset.url = url;
previewItem.dataset.fileId = fileData.id;
previewItem.innerHTML = '';
if (fileData.mime.startsWith('image/')) {
const img = document.createElement('img');
img.src = url;
img.loading = 'lazy';
previewItem.appendChild(img);
} else if (fileData.mime.startsWith('video/')) {
const vid = document.createElement('video');
vid.src = url;
vid.muted = true;
vid.preload = 'metadata';
previewItem.appendChild(vid);
} else {
const icon = document.createElement('i');
icon.className = 'fa-solid fa-music';
previewItem.appendChild(icon);
}
const nameEl = document.createElement('span');
nameEl.className = 'cf-filename';
nameEl.textContent = file.name;
previewItem.appendChild(nameEl);
const removeBtn = document.createElement('button');
removeBtn.className = 'cf-remove-btn';
removeBtn.title = removeLabel;
removeBtn.innerHTML = '