processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
return `[video](${fullUrl})`;
});
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
return `[audio](${fullUrl})`;
});
// 3. Render Markdown for the line.
// Protect URLs, already-formed Markdown link/image tokens, AND emoji shortcodes
// from the italic-prevention escaping pass so that underscores in query params
// (e.g. ?v=_FcvmypiHg4) and emoji names (e.g. :my_emoji:) are never corrupted.
const mdProtected = [];
// Match [text](url) /  tokens, bare http(s) URLs, AND :emoji: shortcodes
let mdSafe = processedLine.replace(
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+|:[a-z0-9_]+:/g,
(match) => {
const idx = mdProtected.length;
mdProtected.push(match);
return `\x02MDURL${idx}\x03`;
}
);
// Escape * and _ only in the non-URL/non-emoji portions
mdSafe = mdSafe
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_');
// Restore protected tokens
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
let rendered = marked.parseInline
? marked.parseInline(mdSafe, { renderer: renderer })
: marked.parse(mdSafe, { renderer: renderer }).replace(/|<\/p>/g, '');
// 4. Restore mention placeholders (after markdown so usernames are never escaped)
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
// 5. Emojis
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
return rendered;
});
let md = renderedLines.join('\n');
// YouTube embed: replace anchor links pointing to YouTube with an embedded player
// Respects per-user preference (session) when logged in; falls back to global config flag for guests.
const embedYoutube = window.f0ckSession
? window.f0ckSession.embed_youtube_in_comments !== false
: window.f0ckEmbedYoutubeInComments !== false;
if (embedYoutube) {
md = md.replace(
/]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
(match, videoId) => {
return `VIDEO `;
}
);
}
// Vocaroo embed
md = md.replace(
/ ]*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;
return ` `;
}
);
// Abyss label replacement
md = md.replace(
/ ]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
(match, abyssId) => {
return ` /abyss/${abyssId} `;
}
);
const mediaHosts = [escapedSiteHost];
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
window.f0ckAllowedImages.forEach(h => {
const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
mediaHosts.push(`(?:[a-z0-9-]+\\.)*${escaped}`);
});
}
const mediaHostsPart = mediaHosts.join('|');
const mediaDomainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${mediaHostsPart})|(?=\\/[a-zA-Z0-9_\\-]))`;
// Video embed: replace anchor links pointing to video files from allowed hosters with a video player
const videoEmbedRegex = new RegExp(`]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
md = md.replace(videoEmbedRegex, (match, url) => {
const isConvertedGif = url.endsWith('#gif');
const cleanUrl = url.replace(/#gif$/, '');
let deleteBtn = '';
if (this.isAdmin && cleanUrl.startsWith('/c/')) {
const filename = cleanUrl.substring(3);
deleteBtn = `[x] `;
}
if (isConvertedGif) {
return ` ${deleteBtn} `;
}
return ` ${deleteBtn} `;
});
// Audio embed: replace anchor links pointing to audio files from allowed hosters with an audio player
const audioEmbedRegex = new RegExp(` ]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
md = md.replace(audioEmbedRegex, (match, url) => {
let deleteBtn = '';
if (this.isAdmin && url.startsWith('/c/')) {
const filename = url.substring(3);
deleteBtn = `[x] `;
}
return ` ${deleteBtn} `;
});
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
let prevMd;
let iterations = 0;
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
do {
prevMd = md;
md = md.replace(spoilerRegex, (match, content) => {
return `${content} `;
});
iterations++;
} while (md !== prevMd && iterations < 10);
// Handle blur [blur]text[/blur] (supports nesting)
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
iterations = 0;
do {
prevMd = md;
md = md.replace(blurRegex, (match, content) => {
return `${content} `;
});
iterations++;
} while (md !== prevMd && iterations < 10);
// Restore protected code blocks
md = md.replace(/BLOCKPORTALX(\d+)X/g, (match, index) => {
return codeBlocks[index] || '';
});
if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
md = Sanitizer.clean(md);
}
// Append the "show full comment" button AFTER sanitization — the sanitizer
// whitelist strips elements for XSS safety, but this button is ours.
if (truncated) {
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
md += ``;
}
return md;
} catch (e) {
console.error('Markdown error:', e);
let fallback = this.escapeHtml(content);
if (truncated) {
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
fallback += ` `;
}
return fallback;
}
}
/**
* Force-play videos with autoplay attribute in a container.
* Browsers often block autoplay on dynamically inserted elements;
* calling .play() explicitly after DOM insertion resolves this.
*/
static autoplayConvertedGifs(container) {
if (!container) return;
const videos = container.querySelectorAll('video.autoplay-gif');
videos.forEach(v => {
v.autoplay = true;
v.muted = true;
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true });
});
});
}
static playEmojiVideos(container) {
if (!container) return;
container.querySelectorAll('video.emoji').forEach(v => {
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
});
});
}
buildBacklinkMap(comments) {
this.backlinkMap = {};
const process = (c) => {
if (!c.content) return;
// Scan for >>ID patterns
const matches = c.content.matchAll(/(?>(\d+)/g);
for (const match of matches) {
const targetId = match[1];
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
this.backlinkMap[targetId].add(c.id);
}
// Also treat parent_id as a direct reply
if (c.parent_id) {
const targetId = String(c.parent_id);
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
this.backlinkMap[targetId].add(c.id);
}
};
const scan = (list) => {
list.forEach(c => {
process(c);
if (c.replies && c.replies.length > 0) scan(c.replies);
});
};
scan(comments);
}
renderEmoji(match, name) {
if (this.customEmojis && this.customEmojis[name]) {
const url = this.customEmojis[name];
if (url.endsWith('.webm')) {
return ` `;
}
return ` `;
}
return match;
}
startLiveTimestamps() {
// Update timestamps every 30 seconds
setInterval(() => {
const timestamps = this.container.querySelectorAll('.comment-time.timeago');
timestamps.forEach(el => {
// data-iso stores the raw ISO date for timeAgo calculation;
// the tooltip attribute holds the human-readable formatted date.
const isoStr = el.getAttribute('data-iso') || el.getAttribute('tooltip');
if (isoStr) {
el.textContent = this.timeAgo(isoStr);
// Keep tooltip in human-readable format
if (window.f0ckFormatDateFull) {
el.setAttribute('tooltip', window.f0ckFormatDateFull(isoStr));
}
}
});
}, 30000);
}
updateCommentBacklinks(targetId, replierId) {
if (!this.backlinkMap) this.backlinkMap = {};
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
this.backlinkMap[targetId].add(replierId);
const targetEl = document.getElementById('c' + targetId);
if (targetEl) {
const headerLeft = targetEl.querySelector('.comment-header-left');
if (headerLeft) {
let span = headerLeft.querySelector('.comment-backlinks');
if (!span) {
span = document.createElement('span');
span.className = 'comment-backlinks';
headerLeft.appendChild(span);
}
// Check if already present
if (!span.querySelector(`a[data-id="${replierId}"]`)) {
const link = document.createElement('a');
link.href = `#c${replierId}`;
link.className = 'comment-context-link';
link.dataset.id = replierId;
link.textContent = `>>${replierId}`;
span.appendChild(document.createTextNode(' '));
span.appendChild(link);
}
}
}
}
renderComment(comment, currentUserId, isReply = false, isLinear = false) {
const isDeleted = comment.is_deleted;
const isPinned = comment.is_pinned;
// Add @mention prefix if this is a reply to a reply
const content = isDeleted ? '[deleted] ' : this.renderCommentContent(comment.content, comment.id);
const date = new Date(comment.created_at).toLocaleString();
// Admin buttons
let adminButtons = '';
if (this.isAdmin && !isDeleted) {
const pinIcon = isPinned ? this.icons.unpin : this.icons.pin;
adminButtons = `${pinIcon} ${this.icons.edit} ${this.icons.delete} `;
}
let userDeleteButton = '';
if (!this.isAdmin && !isDeleted && window.f0ckSession?.logged_in && window.f0ckSession?.allow_comment_deletion) {
if (comment.username && comment.username === window.f0ckSession.user) {
userDeleteButton = `${this.icons.delete} `;
}
}
const pinnedBadge = isPinned ? `${this.icons.pinned} ` : '';
const commentClass = isReply ? 'comment reply' : 'comment';
// Build replies HTML (only for root comments, max 1 level deep)
let repliesHtml = '';
if (!isReply && !isLinear && comment.replies && comment.replies.length > 0) {
repliesHtml = ``;
}
const timeAgo = this.timeAgo(comment.created_at);
const isoDate = new Date(comment.created_at).toISOString();
const fullDate = window.f0ckFormatDateFull
? window.f0ckFormatDateFull(comment.created_at)
: isoDate;
// Parent context marker removed (redundant with back-references)
let contextMarker = '';
// Back-references (replies to this comment)
let backlinkHtml = '';
if (this.backlinkMap && this.backlinkMap[comment.id]) {
const repliers = Array.from(this.backlinkMap[comment.id]);
if (repliers.length > 0) {
backlinkHtml = ``;
}
}
return `${repliesHtml}`;
}
timeAgo(date) {
if (window.f0ckTimeAgo) return window.f0ckTimeAgo(date);
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
if (seconds < 5) return 'just now';
const intervals = [
{ label: 'year', seconds: 31536000 },
{ label: 'month', seconds: 2592000 },
{ label: 'day', seconds: 86400 },
{ label: 'hour', seconds: 3600 },
{ label: 'minute', seconds: 60 },
{ label: 'second', seconds: 1 }
];
for (const interval of intervals) {
const count = Math.floor(seconds / interval.seconds);
if (count >= 1) {
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
escapeHtml(unsafe) {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
}
renderCommentAttachments(files, content = '') {
if (!files || files.length === 0) return '';
const items = files.map(f => {
const url = `/c/${f.dest}`;
if (content.includes(url)) return ''; // Skip if already rendered in content
if (f.mime.startsWith('image/')) {
return ` `;
} else if (f.mime.startsWith('video/')) {
return `
`;
} else if (f.mime.startsWith('audio/')) {
return ``;
}
return '';
}).join('');
return items ? `` : '';
}
renderCommentPoll(poll, commentId, commentUsername) {
if (!poll) return '';
const i18n = window.f0ckI18n || {};
const session = window.f0ckSession || {};
const total = poll.total_votes || 0;
const voted = !!poll.user_vote_option_id;
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
const isAnon = poll.is_anonymous !== false;
const canDelete = session.logged_in && (session.is_admin || session.is_moderator || session.user === commentUsername);
const optionsHtml = (poll.options || []).map(opt => {
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
const isVoted = poll.user_vote_option_id === opt.id;
const clickable = session.logged_in && !expired && !voted;
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
? `${opt.voters.map(v => {
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
const name = String(u.username || '');
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
return name ? `
` : '';
}).join('')}
`
: '';
return `
${this.escapeHtml(opt.text)}
${pct}%
${isVoted ? `
` : ''}
${voterAvatars}
`;
}).join('');
const deleteBtn = canDelete
? ` `
: '';
const anonBadge = isAnon
? ` `
: ` `;
return ``;
}
renderInput(parentId = null) {
const i18n = window.f0ckI18n || {};
const session = window.f0ckSession || {};
const placeholder = i18n.write_comment || 'Write a comment...';
const postLabel = i18n.post || 'Post';
const cancelLabel = i18n.cancel || 'Cancel';
const attachLabel = i18n.attach_file || 'Attach file';
const pollLabel = i18n.poll_btn_title || 'Create poll';
const maxLen = session.comment_max_length;
const maxLenAttr = (maxLen !== null && maxLen !== undefined) ? ` maxlength="${maxLen}"` : '';
const counter = (maxLen !== null && maxLen !== undefined)
? `0 / ${maxLen} `
: '';
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments;
const multiFile = session.fileupload_comments_multifile;
const attachBtn = fileUploadEnabled
? ``
: '';
const pollEnabled = session.logged_in && session.enable_comment_polls;
const pollBtn = pollEnabled
? ``
: '';
return `
${counter}
${attachBtn}
${pollBtn}
${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 || textarea.disabled) return;
const wrap = textarea.closest('.comment-input');
if (!wrap) return;
const submitBtn = wrap.querySelector('.submit-comment');
if (submitBtn && !submitBtn.disabled && !submitBtn.classList.contains('loading')) {
e.preventDefault();
e.stopPropagation();
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();
}
});
// File input change: store the File object locally and show a preview using a
// local object URL. The actual upload happens at submit time, so no server round-
// trip occurs here and there is nothing to orphan.
this.container.addEventListener('change', (e) => {
if (!e.target.matches('.comment-file-input')) return;
const fileInput = e.target;
const wrap = fileInput.closest('.comment-input');
if (!wrap) return;
// Clear the attach-pending flag set in the click handler.
wrap._attachPending = false;
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;
}
// Use saved cursor position (set when attach btn was clicked, before file picker
// dismissed the keyboard on mobile causing selectionStart to become 0).
const savedPos = wrap._savedCursorPos ?? textarea.selectionStart;
const savedEnd = wrap._savedCursorEnd ?? textarea.selectionEnd;
wrap._savedCursorPos = null;
wrap._savedCursorEnd = null;
// Insert a placeholder token so the URL ends up in the right spot in the text
// once we know the real dest after the server-side upload at submit time.
const placeholderToken = `[attachment:${file.name}]`;
const before = textarea.value.substring(0, savedPos);
const after = textarea.value.substring(savedEnd);
const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
textarea.value = before + sep + placeholderToken + after;
// Build local preview (no server call yet)
if (previewArea) {
const objectUrl = URL.createObjectURL(file);
const previewItem = document.createElement('div');
previewItem.className = 'cf-preview-item';
previewItem.dataset.placeholderToken = placeholderToken;
previewItem.dataset.originalFilename = file.name;
previewItem.dataset.mime = file.type || 'application/octet-stream';
// Store the File object directly on the element for retrieval at submit time
previewItem._stagedFile = file;
previewItem._objectUrl = objectUrl;
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = objectUrl;
img.loading = 'lazy';
previewItem.appendChild(img);
} else if (file.type.startsWith('video/')) {
const vid = document.createElement('video');
vid.src = objectUrl;
vid.muted = true;
vid.preload = 'metadata';
previewItem.appendChild(vid);
} else {
const icon = document.createElement('i');
icon.className = file.type.startsWith('audio/') ? 'fa-solid fa-music' : 'fa-solid fa-file';
previewItem.appendChild(icon);
}
const nameEl = document.createElement('span');
nameEl.className = 'cf-filename';
nameEl.textContent = file.name;
previewItem.appendChild(nameEl);
const spoilerBtn = document.createElement('button');
spoilerBtn.className = 'cf-spoiler-btn';
spoilerBtn.title = 'Toggle spoiler';
spoilerBtn.innerHTML = 'S ';
spoilerBtn.type = 'button';
previewItem.appendChild(spoilerBtn);
const removeBtn = document.createElement('button');
removeBtn.className = 'cf-remove-btn';
removeBtn.title = removeLabel;
removeBtn.innerHTML = ' ';
removeBtn.type = 'button';
previewItem.appendChild(removeBtn);
previewArea.appendChild(previewItem);
}
}
fileInput.value = '';
});
this.container.addEventListener('click', async (e) => {
_f0ckDebug('[DEBUG] Click on container:', e.target);
const target = e.target;
// Toggling Scroll Action
const scrollBtn = target.closest('.scroll-to-bottom');
if (scrollBtn) {
if (scrollBtn.classList.contains('is-at-bottom')) {
// Scroll to Top of the page
window.scrollTo({
top: 0,
behavior: 'smooth'
});
} else {
// Scroll to Bottom of comments
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice') || this.container.querySelector('.login-placeholder');
if (bottomElement) {
bottomElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
this.container.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}
return;
}
// Attach file button
if (target.matches('.comment-attach-btn') || target.closest('.comment-attach-btn')) {
const wrap = target.closest('.comment-input');
const textarea = wrap?.querySelector('textarea');
if (textarea) {
wrap._savedCursorPos = textarea.selectionStart;
wrap._savedCursorEnd = textarea.selectionEnd;
}
// Flag that a file picker is open. Checked by the visibilitychange
// guard in f0ckm.js so it doesn't call loadComments (and replace the
// textarea DOM) while the mobile file picker is open.
// Cleared at the start of the change handler, or left to expire
// harmlessly if the user cancels the picker.
if (wrap) wrap._attachPending = true;
const fileInput = wrap?.querySelector('.comment-file-input');
if (fileInput) fileInput.click();
return;
}
// Attach file as spoiler: toggle spoiler marker on a staged preview item
const spoilerToggle = target.closest('.cf-spoiler-btn');
if (spoilerToggle) {
const previewItem = spoilerToggle.closest('.cf-preview-item');
if (previewItem) {
// Staged items use a placeholder token in the textarea instead of a real URL.
// Toggle a data attribute; the actual [spoiler] wrapping is applied at submit.
const isSpoiler = previewItem.classList.toggle('cf-is-spoiler');
spoilerToggle.title = isSpoiler ? 'Remove spoiler' : 'Toggle spoiler';
}
return;
}
// Remove file preview + strip placeholder token from textarea
if (target.matches('.cf-remove-btn') || target.closest('.cf-remove-btn')) {
const previewItem = target.closest('.cf-preview-item');
if (previewItem) {
const token = previewItem.dataset.placeholderToken;
if (token) {
const wrap = previewItem.closest('.comment-input');
const textarea = wrap?.querySelector('textarea');
if (textarea) {
const escaped = token.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
textarea.value = textarea.value
.replace(new RegExp('\\n?' + escaped + '\\n?'), '\n')
.replace(/^\n|\n$/g, '');
}
}
// Revoke the local object URL to free memory
if (previewItem._objectUrl) URL.revokeObjectURL(previewItem._objectUrl);
// No server call needed — file was never uploaded
previewItem.remove();
}
return;
}
// Poll button — toggle poll builder
if (target.closest('.comment-poll-btn')) {
const btn = target.closest('.comment-poll-btn');
const wrap = btn.closest('.comment-input');
if (!wrap) return;
const existing = wrap.querySelector('.poll-builder');
if (existing) {
existing.remove();
btn.classList.remove('active');
return;
}
const i18n = window.f0ckI18n || {};
const builder = document.createElement('div');
builder.className = 'poll-builder';
builder.innerHTML = `
${i18n.poll_add_option || 'Add option'}
${i18n.poll_anonymous || 'Anonymous'}
${i18n.poll_remove || 'Remove poll'}
`;
// Insert before input-actions
const actions = wrap.querySelector('.input-actions');
if (actions) wrap.insertBefore(builder, actions);
else wrap.appendChild(builder);
btn.classList.add('active');
builder.querySelector('.poll-question-input').focus();
return;
}
// Poll builder — add option
if (target.closest('.poll-add-option-btn')) {
const list = target.closest('.poll-builder')?.querySelector('.poll-options-list');
if (!list) return;
if (list.querySelectorAll('.poll-option-input').length >= 10) return;
const i18n = window.f0ckI18n || {};
const inp = document.createElement('input');
inp.className = 'poll-option-input';
inp.type = 'text';
inp.placeholder = i18n.poll_option_placeholder || 'Option...';
inp.maxLength = 100;
list.appendChild(inp);
inp.focus();
return;
}
// Poll builder — remove
if (target.closest('.poll-remove-btn')) {
const builder = target.closest('.poll-builder');
if (!builder) return;
const wrap = builder.closest('.comment-input');
if (wrap) wrap.querySelector('.comment-poll-btn')?.classList.remove('active');
builder.remove();
return;
}
// Poll option — vote
if (target.closest('.poll-option-clickable')) {
const opt = target.closest('.poll-option-clickable');
const pollId = opt.dataset.pollId;
const optionId = opt.dataset.optionId;
const commentId = opt.dataset.commentId;
if (!pollId || !optionId) return;
const pollWidget = opt.closest('.comment-poll');
// Disable all options immediately
pollWidget?.querySelectorAll('.poll-option-clickable').forEach(o => o.classList.remove('poll-option-clickable'));
fetch(`/api/polls/${pollId}/vote`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: `option_id=${optionId}`
}).then(r => r.json()).then(data => {
if (!data.success) {
if (window.flashMessage) window.flashMessage(data.message || 'Vote failed', 2500, 'error');
return;
}
// Patch poll widget in-place
if (!pollWidget) return;
const i18n = window.f0ckI18n || {};
const total = data.total_votes || 0;
pollWidget.querySelector('.poll-total').textContent = `${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}`;
const isAnon = pollWidget.dataset.isAnonymous !== '0';
(data.options || []).forEach(updated => {
const el = pollWidget.querySelector(`.poll-option[data-option-id="${updated.id}"]`);
if (!el) return;
const pct = total > 0 ? Math.round((updated.vote_count / total) * 100) : 0;
el.querySelector('.poll-option-bar').style.width = pct + '%';
el.querySelector('.poll-option-pct').textContent = pct + '%';
if (updated.id === data.user_vote_option_id) {
el.classList.add('poll-option-voted');
if (!el.querySelector('.poll-vote-check')) {
el.insertAdjacentHTML('beforeend', ` `);
}
}
// Update voter list for public polls
if (!isAnon && Array.isArray(updated.voters)) {
let votersEl = el.querySelector('.poll-option-voters');
if (updated.voters.length > 0) {
const html = updated.voters.map(v => {
const u = typeof v === 'object' ? v : { username: v, avatar: null, avatar_file: null };
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
return ` `;
}).join('');
if (votersEl) votersEl.innerHTML = html;
else el.insertAdjacentHTML('beforeend', `${html}
`);
} else if (votersEl) {
votersEl.remove();
}
}
});
}).catch(() => {
if (window.flashMessage) window.flashMessage('Network error', 2500, 'error');
});
return;
}
// Poll delete button
if (target.closest('.poll-delete-btn')) {
const btn = target.closest('.poll-delete-btn');
const pollId = btn.dataset.pollId;
if (!pollId) return;
if (!confirm('Delete this poll?')) return;
fetch(`/api/polls/${pollId}/delete`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
}).then(r => r.json()).then(data => {
if (data.success) {
const widget = btn.closest('.comment-poll');
if (widget) {
widget.style.transition = 'opacity 0.3s';
widget.style.opacity = '0';
setTimeout(() => widget.remove(), 300);
}
} else {
if (window.flashMessage) window.flashMessage(data.message || 'Delete failed', 2500, 'error');
}
}).catch(() => {
if (window.flashMessage) window.flashMessage('Network error', 2500, 'error');
});
return;
}
// Submit Comment
if (target.closest('.submit-comment')) {
this.handleSubmit(e);
return;
}
// Cancel Reply
if (target.closest('.cancel-reply')) {
const form = target.closest('.reply-input');
if (form) form.remove();
return;
}
// Load full comment (expand truncated)
const loadFullBtn = target.closest('.load-full-comment-btn');
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
// Append "see less" button after full content
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
contentEl.insertAdjacentHTML('beforeend',
``
);
}
}
return;
}
// Collapse full comment back to truncated view
const collapseBtn = target.closest('.collapse-comment-btn');
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
}
}
return;
}
// Comment Context Link (>>ID)
const contextLink = target.closest('.comment-context-link');
if (contextLink) {
e.preventDefault();
const targetId = contextLink.dataset.id;
this.scrollToComment(targetId, 0, true);
// Highlight effect
const targetEl = document.getElementById('c' + targetId);
if (targetEl) {
targetEl.classList.add('highlight-comment');
setTimeout(() => targetEl.classList.remove('highlight-comment'), 2000);
}
return;
}
// User Delete
const delBtn = target.closest('.delete-btn');
if (delBtn) {
const id = delBtn.dataset.id;
ModAction.confirm('Delete Comment', `Are you sure you want to delete comment ${id} ?`, async () => {
const res = await fetch(`/api/comments/${id}/delete`, {
method: 'POST',
headers: {
'x-csrf-token': window.f0ckSession?.csrf_token
}
});
const json = await res.json();
if (json.success) {
const sidebarEl = document.getElementById('sc' + id);
if (sidebarEl) {
sidebarEl.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
sidebarEl.style.opacity = '0';
sidebarEl.style.transform = 'scale(0.95)';
setTimeout(() => sidebarEl.remove(), 300);
}
if (window._sidebarActivityCache) {
window._sidebarActivityCache = window._sidebarActivityCache.filter(c => String(c.id) !== String(id));
}
const commentEl = document.getElementById(`c${id}`);
if (commentEl) {
commentEl.classList.add('deleted');
const contentEl = commentEl.querySelector('.comment-content');
if (contentEl) {
contentEl.innerHTML = '[deleted] ';
}
const actionsEl = commentEl.querySelector('.comment-actions');
if (actionsEl) {
actionsEl.innerHTML = ''; // Remove reply/quote/admin buttons
}
// Remove attachments if present
const attachmentsEl = commentEl.querySelector('.comment-attachments, .media-pills');
if (attachmentsEl) attachmentsEl.remove();
} else {
this.loadComments();
}
} else {
throw new Error(json.message || 'Failed to delete');
}
}, { hideReason: true });
return;
}
// Admin Delete
const adminDelBtn = target.closest('.admin-delete-btn');
if (adminDelBtn) {
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded. Are you a moderator?');
const id = adminDelBtn.dataset.id;
ModAction.confirm('Delete Comment', `Are you sure you want to delete comment ${id} ?`, async (reason) => {
const params = new URLSearchParams();
if (reason) params.append('reason', reason);
const res = await fetch(`/api/comments/${id}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const json = await res.json();
if (json.success) {
const sidebarEl = document.getElementById('sc' + id);
if (sidebarEl) {
sidebarEl.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
sidebarEl.style.opacity = '0';
sidebarEl.style.transform = 'scale(0.95)';
setTimeout(() => sidebarEl.remove(), 300);
}
if (window._sidebarActivityCache) {
window._sidebarActivityCache = window._sidebarActivityCache.filter(c => String(c.id) !== String(id));
}
const commentEl = document.getElementById(`c${id}`);
if (commentEl) {
commentEl.classList.add('deleted');
const contentEl = commentEl.querySelector('.comment-content');
if (contentEl) {
contentEl.innerHTML = '[deleted] ';
}
const actionsEl = commentEl.querySelector('.comment-actions');
if (actionsEl) {
actionsEl.innerHTML = ''; // Remove reply/quote/admin buttons
}
// Remove attachments if present
const attachmentsEl = commentEl.querySelector('.comment-attachments, .media-pills'); // Try common classes
if (attachmentsEl) attachmentsEl.remove();
} else {
this.loadComments();
}
} else {
throw new Error(json.message || 'Failed to delete');
}
}, { allowEmpty: window.f0ckSession?.is_admin });
return;
}
// Admin Delete Attachment
const adminDelAttachBtn = target.closest('.admin-delete-attachment-btn');
if (adminDelAttachBtn) {
const filename = adminDelAttachBtn.dataset.filename;
if (filename) {
if (confirm('Are you sure you want to delete this attachment?')) {
const res = await fetch('/api/comments/attachment/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: `filename=${encodeURIComponent(filename)}`
});
const json = await res.json();
if (json.success) {
const wrapper = adminDelAttachBtn.closest('.image-embed-wrap') || adminDelAttachBtn.closest('.video-embed-wrap') || adminDelAttachBtn.closest('.audio-embed-wrap');
if (wrapper) {
const span = document.createElement('span');
span.className = 'attachment-removed-text';
span.textContent = '[attachment removed]';
wrapper.parentNode.replaceChild(span, wrapper);
} else {
this.loadComments();
}
} else {
alert('Failed to delete attachment: ' + (json.message || 'Error'));
}
}
}
return;
}
// Admin Pin
const adminPinBtn = target.closest('.admin-pin-btn');
if (adminPinBtn) {
const id = adminPinBtn.dataset.id;
const res = await fetch(`/api/comments/${id}/pin`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const json = await res.json();
if (json.success) this.loadComments(id);
else alert('Failed to pin: ' + (json.message || 'Error'));
return;
}
// Admin Edit
const adminEditBtn = target.closest('.admin-edit-btn');
if (adminEditBtn) {
const id = adminEditBtn.dataset.id;
const currentContent = adminEditBtn.dataset.content;
const commentEl = document.getElementById('c' + id);
const contentEl = commentEl.querySelector('.comment-content');
const originalHtml = contentEl.innerHTML;
contentEl.innerHTML = `
Save
`;
this.setupEmojiPicker(contentEl);
// We don't need to bind events here because delegation handles the new buttons too!
// But we need to store originalHtml somewhere or handle Cancel specifically?
// Actually, for Cancel of edit, we need state?
// We can store original HTML in a dataset of the container or just re-render/reload?
// Re-loading is safer for now, or we can use a closure if we attached listener here.
// Since this is Delegation, "Cancel" needs to know what to restore.
// Let's attach a "once" listener on the cancel button immediately here?
// NO, mixing strategies is confusing.
// Let's put original HTML in dataset encoded? No, potentially large.
// Simpler: On Cancel, just reload the comment (fetch or just re-render whole list).
// Or: Attach a specialized listener strictly for this temporary element.
const cancelBtn = contentEl.querySelector('.cancel-edit-btn');
cancelBtn.onclick = () => { contentEl.innerHTML = originalHtml; };
const saveBtn = contentEl.querySelector('.save-edit-btn');
saveBtn.onclick = async () => {
const newContent = contentEl.querySelector('.edit-textarea').value;
if (!newContent.trim()) return alert('Cannot be empty');
const params = new URLSearchParams();
params.append('content', newContent);
const res = await fetch(`/api/comments/${id}/edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const json = await res.json();
if (json.success) this.loadComments(id);
else alert('Failed to edit: ' + (json.message || 'Error'));
};
return;
}
// Reply
// Reply Button (ID only)
const replyBtn = target.closest('.reply-btn');
if (replyBtn) {
const id = replyBtn.dataset.id;
const commentEl = replyBtn.closest('[id^="c"]');
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
if (body) {
// Check if any reply input is ALREADY open
let textarea = document.querySelector('.comment-input.reply-input textarea');
// If none open, open the local one for this comment
if (!textarea && !body.querySelector('.reply-input')) {
const div = document.createElement('div');
div.innerHTML = this.renderInput(id);
body.appendChild(div.firstElementChild);
const newForm = body.querySelector('.reply-input');
this.setupEmojiPicker(newForm);
textarea = newForm.querySelector('textarea');
} else if (!textarea) {
textarea = body.querySelector('.reply-input textarea');
}
if (textarea) {
const quote = `>>${id} `;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
textarea.value = val.substring(0, start) + quote + val.substring(end);
textarea.focus({ preventScroll: true });
textarea.selectionStart = textarea.selectionEnd = start + quote.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
this._scrollReplyIntoView(textarea);
}
}
return;
}
// Quote Button (Full Text Quote - Old Style)
const quoteBtn = target.closest('.quote-btn');
if (quoteBtn) {
const id = quoteBtn.dataset.id;
const body = quoteBtn.closest('.comment-body');
if (body) {
this.quoteComment(id, quoteBtn, body);
}
return;
}
// Subscribe
const subBtn = target.closest('#subscribe-btn');
if (subBtn) {
const isSubscribed = subBtn.textContent === 'Subscribed';
subBtn.textContent = 'Wait...';
try {
const res = await fetch(`/api/subscribe/${this.itemId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const json = await res.json();
if (json.success) {
this.syncSubscribeButton(json.subscribed);
window.flashMessage((window.f0ckI18n && (json.subscribed ? window.f0ckI18n.subscribed_thread : window.f0ckI18n.unsubscribed_thread)) || (json.subscribed ? 'SUBSCRIBED TO THREAD' : 'UNSUBSCRIBED FROM THREAD'));
} else {
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
alert('Failed to toggle subscription');
}
} catch (e) {
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
}
return;
}
// Lock
const lockBtn = target.closest('#lock-thread-btn');
if (lockBtn) {
const action = this.isLocked ? 'unlock' : 'lock';
lockBtn.disabled = true;
const res = await fetch(`/api/comments/${this.itemId}/lock`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const json = await res.json();
lockBtn.disabled = false;
if (json.success) {
this.isLocked = json.is_locked;
lockBtn.title = this.isLocked ? 'Unlock Thread' : 'Lock Thread';
window.flashMessage(this.isLocked ? 'THREAD LOCKED' : 'THREAD UNLOCKED');
this.loadComments();
} else {
window.flashMessage(json.msg || 'Failed to lock/unlock', 3000, 'error');
}
return;
}
// Permalinks & Timestamp clicks
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
const el = target.closest('.comment-permalink, .comment-time');
if (el) {
const id = el.dataset.id;
const commentEl = el.closest('[id^="c"]');
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
if (body) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.quoteComment(id, el, body);
}
}
return;
}
});
}
async handleSubmit(e) {
const wrap = e.target.closest('.comment-input');
const submitBtn = wrap.querySelector('.submit-comment');
const textarea = wrap.querySelector('textarea');
const parentId = wrap.dataset.parent || null;
// Collect staged (not-yet-uploaded) files from preview items
const stagedItems = [];
const previewArea = wrap.querySelector('.comment-file-preview');
if (previewArea) {
previewArea.querySelectorAll('.cf-preview-item').forEach(item => {
if (item._stagedFile) stagedItems.push(item);
});
}
// Collect poll data from builder (if present)
let pollPayload = null;
const pollBuilder = wrap.querySelector('.poll-builder');
if (pollBuilder) {
const question = pollBuilder.querySelector('.poll-question-input')?.value.trim() || '';
const options = [...pollBuilder.querySelectorAll('.poll-option-input')]
.map(i => i.value.trim())
.filter(Boolean);
const isAnonymous = pollBuilder.querySelector('.poll-anon-checkbox')?.checked !== false;
if (question && options.length >= 2) {
pollPayload = { question, options, is_anonymous: isAnonymous };
}
}
// Read current textarea value (we'll patch placeholder tokens after upload)
let text = textarea.value.trim();
if (!text && stagedItems.length === 0 && !pollPayload) {
textarea.reportValidity();
return;
}
if (submitBtn.classList.contains('loading') || submitBtn.disabled) return;
// ── Upload all staged files now (at submit time) ───────────────────────
const fileIds = [];
const files = [];
if (stagedItems.length > 0) {
submitBtn.classList.add('loading');
submitBtn.disabled = true;
textarea.disabled = true;
const session = window.f0ckSession || {};
const csrf = session.csrf_token || '';
const i18n = window.f0ckI18n || {};
const uploadResults = await Promise.all(stagedItems.map(async (item) => {
const file = item._stagedFile;
const fd = new FormData();
fd.append('file', file);
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) {
return { item, fileData: json.files[0] };
} else {
if (window.flashMessage) window.flashMessage(json.msg || (i18n.upload_failed || 'Upload failed'), 3000, 'error');
return { item, fileData: null };
}
} catch (err) {
if (window.flashMessage) window.flashMessage((i18n.upload_failed || 'Upload failed') + ': ' + err.message, 3000, 'error');
return { item, fileData: null };
}
}));
// Any upload that failed: abort the whole submit so the user can retry
const anyFailed = uploadResults.some(r => !r.fileData);
if (anyFailed) {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
textarea.disabled = false;
return;
}
// Patch placeholder tokens in the textarea with real URLs
let currentText = textarea.value;
for (const { item, fileData } of uploadResults) {
const token = item.dataset.placeholderToken;
const isSpoiler = item.classList.contains('cf-is-spoiler');
const rawUrl = `/c/${fileData.dest}${fileData.converted_gif ? '#gif' : ''}`;
const replacement = isSpoiler ? `[spoiler]${rawUrl}[/spoiler]` : rawUrl;
if (token) currentText = currentText.replace(token, replacement);
// Update preview item to reflect its uploaded state
item.dataset.url = rawUrl;
item.dataset.fileId = fileData.id;
item.dataset.dest = fileData.dest;
item.dataset.mime = fileData.mime;
delete item._stagedFile;
if (item._objectUrl) { URL.revokeObjectURL(item._objectUrl); delete item._objectUrl; }
fileIds.push(String(fileData.id));
files.push({
id: fileData.id,
dest: fileData.dest,
mime: fileData.mime,
original_filename: item.dataset.originalFilename
});
}
textarea.value = currentText;
text = currentText.trim();
}
// Start loading state
submitBtn.classList.add('loading');
submitBtn.disabled = true;
textarea.disabled = true;
const originalBtnHtml = null; // no longer needed — loading state is CSS-only
if (window.f0ckDebugSpinner) {
console.log('[DEBUG] window.f0ckDebugSpinner is true. Freezing spinner state for inspection.');
return;
}
// Mark as pending to prevent state restoration while in flight
if (parentId) {
this.pendingSubmissions.add(parentId);
} else {
this.isMainSubmitting = true;
}
let retryCount = 0;
const maxRetries = 20; // Allow several minutes of retrying during restart
const attemptSubmit = async () => {
try {
// Capture video timecode at the moment of submission
const videoEl = document.querySelector('.v0ck video, .v0ck audio');
const videoTime = (videoEl && isFinite(videoEl.duration) && videoEl.currentTime > 0)
? videoEl.currentTime
: null;
const params = new URLSearchParams();
params.append('item_id', this.itemId);
if (parentId) params.append('parent_id', parentId);
params.append('content', text);
if (videoTime !== null) params.append('video_time', videoTime.toFixed(3));
if (fileIds.length > 0) {
params.append('file_ids', fileIds.join(','));
}
if (pollPayload) {
params.append('has_poll', '1');
}
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (!res.ok) {
if (res.status >= 500) {
throw new Error(`Server returned ${res.status}`);
}
// For 4xx errors, we stop and show the error to user (likely validation or auth)
const json = await res.json().catch(() => ({}));
alert('Error: ' + (json.message || `Status ${res.status}`));
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
return;
}
const json = await res.json();
if (json.success) {
// Success cleanup
if (parentId) {
const formRow = wrap.closest('.reply-input');
if (formRow) formRow.remove();
} else {
if (textarea) textarea.value = '';
const counter = wrap.querySelector('.char-counter');
if (counter) {
counter.textContent = `0 / ${counter.dataset.max}`;
counter.classList.remove('near-limit', 'at-limit');
}
const fpArea = wrap.querySelector('.comment-file-preview');
if (fpArea) fpArea.innerHTML = '';
// Remove poll builder after posting
wrap.querySelector('.poll-builder')?.remove();
wrap.querySelector('.comment-poll-btn')?.classList.remove('active');
}
// If there was a poll, attach it now
const commentId = json.comment?.id;
if (pollPayload && commentId) {
const pfd = new FormData();
pfd.append('poll', JSON.stringify(pollPayload));
fetch(`/api/polls/attach/${commentId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: new URLSearchParams(pfd)
}).then(r => r.json()).then(pData => {
if (pData.success && pData.poll) {
// Patch poll into newComment and update DOM
newComment.poll = pData.poll;
const commentEl = document.getElementById('c' + commentId);
if (commentEl) {
const attachmentsEl = commentEl.querySelector('.comment-attachments');
const contentEl = commentEl.querySelector('.comment-content');
const insertAfter = attachmentsEl || contentEl;
if (insertAfter) {
const pollHtml = this.renderCommentPoll(pData.poll, commentId, window.f0ckSession?.user);
insertAfter.insertAdjacentHTML('afterend', pollHtml);
}
}
}
}).catch(() => {});
}
// Notify the right sidebar that a new comment was posted (silent refresh)
document.dispatchEvent(new CustomEvent('f0ck:commentPosted', {
detail: { item_id: this.itemId, comment_id: json.comment?.id, content: text }
}));
// ── Optimistic DOM inject ──────────────────────────────────────────
// Build a minimal comment object from known session + server data so we
// can insert the comment directly without replacing the entire container.
const session = window.f0ckSession || {};
const currentUsername = session.user || this.user;
let resolvedAvatar = null;
let resolvedAvatarFile = null;
if (session.avatar_file) resolvedAvatarFile = session.avatar_file;
else if (session.avatar) resolvedAvatar = session.avatar;
if (!resolvedAvatar && !resolvedAvatarFile && this.lastData && currentUsername) {
const existingByMe = this.lastData.find(c =>
c.username && c.username.toLowerCase() === currentUsername.toLowerCase()
);
if (existingByMe) {
resolvedAvatar = existingByMe.avatar || null;
resolvedAvatarFile = existingByMe.avatar_file || null;
}
}
if (!resolvedAvatar && !resolvedAvatarFile && currentUsername) {
const existingCommentEl = this.container.querySelector(`.comment-author[href="/user/${currentUsername}"]`);
if (existingCommentEl) {
const avatarImg = existingCommentEl.closest('.comment-body')?.previousElementSibling?.querySelector('img');
if (avatarImg) {
const src = avatarImg.getAttribute('src') || '';
if (src.startsWith('/a/')) resolvedAvatarFile = src.slice(3);
else if (src.startsWith('/t/') && src.endsWith('.webp')) resolvedAvatar = src.slice(3, -5);
}
}
}
const newComment = {
id: json.comment.id,
item_id: this.itemId,
parent_id: parentId ? parseInt(parentId, 10) : null,
content: text,
files: files,
created_at: json.comment.created_at || new Date().toISOString(),
username: currentUsername,
user_id: session.id || null,
display_name: session.display_name || null,
avatar: resolvedAvatar,
avatar_file: resolvedAvatarFile,
username_color: session.username_color || null,
is_deleted: false,
is_pinned: false,
video_time: json.comment.video_time ?? null,
replies: [],
replyTo: null,
poll: null
};
// Danmaku: fire immediately (one-shot) + add to future rotation
if (window.danmakuInstance) {
window.danmakuInstance.fire(
text,
session.display_name || currentUsername || '?',
session.username_color || null
);
window.danmakuInstance.addItem({
content: text,
video_time: newComment.video_time ?? null,
display_name: session.display_name || currentUsername || '?',
username_color: session.username_color || null
});
}
if (!this.lastData) this.lastData = [];
const existingInLastData = this.lastData.find(c => c.id === newComment.id);
if (existingInLastData) {
existingInLastData.files = files;
} else {
if (this.sort === 'new') this.lastData.unshift(newComment);
else this.lastData.push(newComment);
}
if (parentId) {
const existingReply = document.getElementById('c' + newComment.id);
if (existingReply) {
const parent = existingReply.parentNode;
const commentHtml = this.renderComment(existingInLastData || newComment, this.lastUserId, true);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const newEl = tmp.firstElementChild;
if (newEl) {
parent.replaceChild(newEl, existingReply);
CommentSystem.playEmojiVideos(newEl);
requestAnimationFrame(() => {
CommentSystem._animateNewComment(newEl, true);
newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
} else {
const parentEl = document.getElementById('c' + parentId);
if (parentEl) {
let repliesEl = parentEl.nextElementSibling;
if (!repliesEl || !repliesEl.classList.contains('comment-replies')) {
repliesEl = document.createElement('div');
repliesEl.className = 'comment-replies';
parentEl.insertAdjacentElement('afterend', repliesEl);
}
const commentHtml = this.renderComment(newComment, this.lastUserId, true);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const commentEl = tmp.firstElementChild;
if (commentEl) {
repliesEl.appendChild(commentEl);
this._ensureTruncationButton(commentEl, newComment.content);
CommentSystem.playEmojiVideos(commentEl);
requestAnimationFrame(() => {
CommentSystem._animateNewComment(commentEl, true);
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
}
}
} else {
const existingTop = document.getElementById('c' + newComment.id);
if (existingTop) {
const parent = existingTop.parentNode;
const commentHtml = this.renderComment(existingInLastData || newComment, this.lastUserId, false);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const newEl = tmp.firstElementChild;
if (newEl) {
parent.replaceChild(newEl, existingTop);
CommentSystem.playEmojiVideos(newEl);
requestAnimationFrame(() => {
CommentSystem._animateNewComment(newEl, true);
newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
} else {
const list = this.container.querySelector('.comments-list');
if (list) {
const commentHtml = this.renderComment(newComment, this.lastUserId, false);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const commentEl = tmp.firstElementChild;
if (commentEl) {
if (this.sort === 'new') {
const scrollNav = list.querySelector('.scroll-nav-wrapper');
if (scrollNav) scrollNav.insertAdjacentElement('afterend', commentEl);
else list.prepend(commentEl);
} else {
list.appendChild(commentEl);
}
this._ensureTruncationButton(commentEl, newComment.content);
CommentSystem.playEmojiVideos(commentEl);
requestAnimationFrame(() => {
CommentSystem._animateNewComment(commentEl, true);
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
}
}
}
if (json.is_new_subscription) {
if (window.flashMessage) window.flashMessage((window.f0ckI18n && window.f0ckI18n.subscribed_thread) || 'SUBSCRIBED TO THREAD');
this.syncSubscribeButton(true);
}
// Update xD score badge immediately from the POST response —
// faster and more reliable than waiting for the SSE NOTIFY.
if (typeof json.xd_score === 'number' && typeof window.updateXdBadgeFromScore === 'function') {
window.updateXdBadgeFromScore(this.itemId, json.xd_score);
}
this._silentSync();
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
} else {
alert('Error: ' + json.message);
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
}
} catch (err) {
console.warn(`[CommentSystem] Submit attempt ${retryCount + 1} failed:`, err);
if (retryCount < maxRetries) {
retryCount++;
// Randomized exponential backoff
const delay = Math.min(1000 * Math.pow(1.5, retryCount) + (Math.random() * 1000), 10000);
_f0ckDebug(`[CommentSystem] Retrying in ${Math.round(delay)}ms...`);
setTimeout(attemptSubmit, delay);
} else {
alert('Failed to send comment after multiple attempts. Please check your connection.');
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
}
}
};
attemptSubmit();
}
// Defensive: ensure the "show full comment" button is present in a rendered comment
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
console.log('[ensureBtn] below threshold, skipping');
return;
}
const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) { console.log('[ensureBtn] no .comment-content found'); return; }
if (contentEl.querySelector('.load-full-comment-btn')) {
console.log('[ensureBtn] button already present');
return;
}
console.log('[ensureBtn] injecting button');
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
contentEl.insertAdjacentHTML('beforeend',
``
);
contentEl.dataset.raw = rawContent;
console.log('[ensureBtn] done, button in DOM:', !!contentEl.querySelector('.load-full-comment-btn'));
}
_finishSubmit(btn, originalHtml, parentId) {
if (parentId) {
this.pendingSubmissions.delete(parentId);
} else {
this.isMainSubmitting = false;
}
// Surgical lookup of the active wrap in the live DOM
let activeWrap = null;
if (this.container) {
if (parentId) {
activeWrap = this.container.querySelector(`#c${parentId} .reply-input`);
} else {
activeWrap = this.container.querySelector('.main-input');
}
}
const wrap = activeWrap || (btn ? btn.closest('.comment-input') : null);
if (wrap) {
const activeBtn = wrap.querySelector('.submit-comment');
const activeTextarea = wrap.querySelector('textarea');
if (activeBtn) {
activeBtn.classList.remove('loading');
activeBtn.disabled = false;
}
if (activeTextarea) {
activeTextarea.disabled = false;
}
}
}
// Silently fetch fresh comment data from the server and update lastData
// without touching the DOM — used after optimistic inserts to stay in sync.
async _silentSync() {
if (!this.itemId) return;
try {
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
const data = await res.json();
if (data.success) {
this.lastData = data.comments;
this.lastUserId = data.user_id;
this.lastIsSubscribed = data.is_subscribed;
if (data.is_admin !== undefined) this.isAdmin = data.is_admin;
if (data.is_locked !== undefined) this.isLocked = data.is_locked;
}
} catch (e) {
// Non-critical: local cache may be slightly stale until next full refresh
console.warn('[CommentSystem] Silent sync failed:', e);
}
}
setupGlobalListeners() {
if (CommentSystem.globalListenersAttached) return;
CommentSystem.globalListenersAttached = true;
// Refresh comments when the tab becomes visible again (e.g. switching back from another tab).
// This handles cases where SSE events were missed while the tab was backgrounded,
// and covers guests where NotificationSystem (which also does this) is absent.
// Guard: ensure the current commentSystem's container is still in the live DOM
// (avoids writing to detached nodes left behind by PJAX navigation).
// Skip if the user is actively typing to avoid clobbering their draft.
// Note: The visibility listener was moved to f0ckm.js NotificationSystem to centralize
// tab-activation polling and avoid redundant network requests.
window.addEventListener('hashchange', () => {
if (window.commentSystem && location.hash && location.hash.startsWith('#c')) {
const id = location.hash.substring(2);
window.commentSystem.scrollToComment(id);
}
});
window.addEventListener('f0ck:emojis_updated', () => {
const cs = window.commentSystem;
if (!cs) return;
CommentSystem.emojiCache = null;
CommentSystem.emojiPacks = null;
CommentSystem.loadingEmojis = false;
cs.loadEmojis(true); // force=true: bypass page-scan, admin just changed emojis
});
// Shortcut 'c' to toggle comments
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
const tag = e.target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (e.key.toLowerCase() === 'c') {
if (window.commentSystem) window.commentSystem.toggleComments();
}
});
// Ctrl+. — focus the comment input
document.addEventListener('keydown', (e) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== '.') return;
const cs = window.commentSystem;
if (!cs || !cs.container) return;
e.preventDefault();
// If comments are hidden, show them first
const isHidden = cs.container.classList.contains('faded-out') || cs.container.style.display === 'none';
if (isHidden) cs.toggleComments();
// Legacy layout: comments are in the normal page flow under .item-main-content
// Modern layout: comments are in a fixed-height sidebar column
const isLegacyLayout = !!cs.container.closest('.item-main-content');
// Wait a tick so the container is visible before trying to focus
setTimeout(() => {
const textarea = cs.container.querySelector('.main-input textarea');
if (textarea) {
// In legacy layout, scroll page to bottom first so the box is fully visible
if (isLegacyLayout) {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
textarea.focus();
if (!isLegacyLayout) {
textarea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, isHidden ? 320 : 0); // 320ms matches the CSS fade-in transition
});
// Global subscribe button listener
document.addEventListener('click', async (e) => {
const subBtn = e.target.closest('#subscribe-btn');
if (subBtn) {
e.preventDefault();
if (subBtn.style.opacity === '0.5') return;
const itemId = subBtn.dataset.itemId || (window.commentSystem ? window.commentSystem.itemId : null);
if (!itemId) return;
subBtn.style.opacity = '0.5';
try {
const res = await fetch(`/api/subscribe/${itemId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const json = await res.json();
if (json.success && window.commentSystem) {
window.commentSystem.syncSubscribeButton(json.subscribed);
window.flashMessage((window.f0ckI18n && (json.subscribed ? window.f0ckI18n.subscribed_thread : window.f0ckI18n.unsubscribed_thread)) || (json.subscribed ? 'SUBSCRIBED TO THREAD' : 'UNSUBSCRIBED FROM THREAD'));
} else if (!json.success) {
alert('Failed to toggle subscription');
}
} catch (e) {
console.error(e);
} finally {
subBtn.style.opacity = '1';
}
}
});
// Global timestamp seek & scroll listener
document.addEventListener('click', (e) => {
const timestampLink = e.target.closest('.comment-timestamp');
if (timestampLink) {
e.preventDefault();
e.stopPropagation();
const seconds = parseFloat(timestampLink.dataset.time);
if (!isNaN(seconds)) {
const mediaElement = document.querySelector('#my-video') || document.querySelector('audio#my-video');
if (mediaElement) {
mediaElement.currentTime = seconds;
if (mediaElement.paused) {
mediaElement.play().catch(() => {});
}
const playerContainer = mediaElement.closest('.v0ck') || mediaElement;
if (playerContainer) {
playerContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}
});
}
toggleComments() {
if (!this.container) return;
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
const layout = this.container.closest('.item-layout-container');
const sidebar = this.container.closest('.item-sidebar-left');
const siblings = sidebar ? [
...sidebar.querySelectorAll('.sidebar-tags-container, .tag-controls')
] : [];
// Check if currently hidden (or slid out)
const isHidden = this.container.classList.contains('faded-out') || this.container.style.display === 'none';
if (isHidden) {
// SHOW: expand grid first, then slide content in
if (layout) layout.classList.remove('sidebar-hidden');
document.body.classList.remove('sidebar-left-hidden');
this.container.style.display = '';
localStorage.setItem('comments_hidden', 'false');
void this.container.offsetWidth; // force reflow so transition fires
this.container.classList.remove('faded-out');
siblings.forEach(el => el.classList.remove('faded-out'));
} else {
// HIDE: slide content out first, then collapse grid
localStorage.setItem('comments_hidden', 'true');
this.container.classList.add('faded-out');
siblings.forEach(el => el.classList.add('faded-out'));
setTimeout(() => {
if (!this.container.classList.contains('faded-out')) return;
this.container.style.display = 'none';
if (layout) layout.classList.add('sidebar-hidden');
document.body.classList.add('sidebar-left-hidden');
}, 300);
}
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
setupEmojiPicker(container) {
const textarea = container.querySelector('textarea');
if (!textarea) return;
if (container.querySelector('.emoji-trigger')) return;
// Attach mentions
if (window.MentionAutocomplete) window.MentionAutocomplete.attach(textarea);
// ── Inline emoji autocomplete ─────────────────────────────────────────
// Portaled to so it escapes parent stacking contexts (overflow,
// z-index scopes) and always renders on top of tags/badges.
const autocomplete = document.createElement('div');
autocomplete.className = 'emoji-autocomplete';
autocomplete.style.display = 'none';
autocomplete.style.overscrollBehavior = 'contain';
document.body.appendChild(autocomplete);
let acActiveIdx = -1;
let acMatches = [];
let acDisplayedCount = 0;
const BATCH_SIZE = 20;
const positionAC = () => {
const rect = textarea.getBoundingClientRect();
autocomplete.style.position = 'fixed';
autocomplete.style.left = rect.left + 'px';
autocomplete.style.width = rect.width + 'px';
// layout-modern: input is near the top of the sidebar → open downward
if (document.body.classList.contains('layout-modern')) {
autocomplete.style.top = rect.bottom + 'px';
autocomplete.style.bottom = 'auto';
} else {
autocomplete.style.bottom = (window.innerHeight - rect.top) + 'px';
autocomplete.style.top = 'auto';
}
};
const hideAC = () => {
autocomplete.style.display = 'none';
autocomplete.innerHTML = '';
acActiveIdx = -1;
acMatches = [];
acDisplayedCount = 0;
};
const getColon = () => {
const pos = textarea.selectionStart;
const text = textarea.value.slice(0, pos);
const match = text.match(/:([a-z0-9_]{0,})$/i);
if (!match) return null;
return { query: match[1].toLowerCase(), colonPos: pos - match[0].length };
};
const appendMoreItems = () => {
if (acDisplayedCount >= acMatches.length) return;
const nextBatch = acMatches.slice(acDisplayedCount, acDisplayedCount + BATCH_SIZE);
nextBatch.forEach((name, i) => {
const idx = acDisplayedCount + i;
const item = document.createElement('div');
item.className = 'emoji-ac-item';
item.dataset.idx = idx;
const url = this.customEmojis[name];
let preview;
if (url && url.endsWith('.webm')) {
preview = document.createElement('video');
preview.src = url;
preview.autoplay = true;
preview.loop = true;
preview.muted = true;
preview.playsInline = true;
} else {
preview = document.createElement('img');
preview.src = url;
preview.alt = name;
preview.loading = 'lazy';
}
const label = document.createElement('span');
label.textContent = `:${name}:`;
item.appendChild(preview);
item.appendChild(label);
item.addEventListener('mousedown', ev => {
ev.preventDefault(); // don't blur textarea
insertEmoji(name);
});
autocomplete.appendChild(item);
});
acDisplayedCount += nextBatch.length;
};
const renderAC = () => {
const hit = getColon();
if (!hit || !this.customEmojis) return hideAC();
const { query } = hit;
acMatches = Object.keys(this.customEmojis).filter(n => n.includes(query));
if (!acMatches.length) return hideAC();
autocomplete.innerHTML = '';
acActiveIdx = -1;
acDisplayedCount = 0;
appendMoreItems(); // Initial batch
positionAC();
autocomplete.style.display = 'flex';
};
const setActive = (idx) => {
const items = autocomplete.querySelectorAll('.emoji-ac-item');
items.forEach(el => el.classList.remove('active'));
if (idx >= 0 && idx < items.length) {
items[idx].classList.add('active');
items[idx].scrollIntoView({ block: 'nearest' });
}
acActiveIdx = idx;
};
const insertEmoji = (name) => {
const hit = getColon();
if (!hit) return;
const before = textarea.value.slice(0, hit.colonPos);
const after = textarea.value.slice(textarea.selectionStart);
const insert = `:${name}:`;
textarea.value = before + insert + after;
const newPos = hit.colonPos + insert.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
hideAC();
};
textarea.addEventListener('input', () => {
// Lazy-load emojis if user starts typing ':' (emoji autocomplete) but cache is empty
if (!CommentSystem.emojiCache && !CommentSystem.loadingEmojis && getColon()) {
this.loadEmojis(true); // force=true: user explicitly wants to pick an emoji
}
renderAC();
});
textarea.addEventListener('keydown', (e) => {
if (autocomplete.style.display === 'none') return;
if (e.key === 'ArrowDown') {
e.preventDefault();
// Load more if we are at the end of current displayed items
if (acActiveIdx === acDisplayedCount - 1 && acDisplayedCount < acMatches.length) {
appendMoreItems();
}
const nextIdx = (acActiveIdx + 1) % acMatches.length;
setActive(nextIdx);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const nextIdx = (acActiveIdx <= 0) ? acMatches.length - 1 : acActiveIdx - 1;
// If jumping to the end, we must load everything (or enough)
if (nextIdx > acDisplayedCount - 1) {
while (acDisplayedCount < acMatches.length) appendMoreItems();
}
setActive(nextIdx);
} else if ((e.key === 'Enter' || e.key === 'Tab') && acActiveIdx >= 0) {
e.preventDefault();
insertEmoji(acMatches[acActiveIdx]);
} else if (e.key === 'Escape') {
hideAC();
}
});
// Lazy load on scroll
autocomplete.addEventListener('scroll', () => {
const threshold = 50;
if (autocomplete.scrollHeight - autocomplete.scrollTop - autocomplete.clientHeight < threshold) {
appendMoreItems();
}
});
// Delay so mousedown on an item fires before blur removes the dropdown
textarea.addEventListener('blur', () => setTimeout(hideAC, 150));
textarea.addEventListener('click', () => { if (getColon()) renderAC(); });
// Reposition when textarea is resized (user drags handle) or window resizes
const ro = new ResizeObserver(() => {
if (autocomplete.style.display !== 'none') positionAC();
});
ro.observe(textarea);
const onWinResize = () => { if (autocomplete.style.display !== 'none') positionAC(); };
window.addEventListener('resize', onWinResize);
// Clean up portal element when this comment system is destroyed
const _origDestroy = this.destroy?.bind(this);
this.destroy = () => {
if (typeof _origDestroy === 'function') _origDestroy();
if (autocomplete && autocomplete.parentNode) autocomplete.parentNode.removeChild(autocomplete);
window.removeEventListener('resize', onWinResize);
ro.disconnect();
};
const trigger = document.createElement('button');
trigger.innerHTML = ' ';
trigger.className = 'emoji-trigger';
const actions = container.querySelector('.input-actions');
if (actions) {
// Add spoiler button
const spoilerBtn = document.createElement('button');
spoilerBtn.innerText = '[S]';
spoilerBtn.className = 'spoiler-trigger';
spoilerBtn.title = 'Insert spoiler tag';
spoilerBtn.addEventListener('click', (e) => {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
const selected = val.substring(start, end);
if (selected) {
const tagStart = '[spoiler]';
const tagEnd = '[/spoiler]';
textarea.value = val.substring(0, start) + tagStart + selected + tagEnd + val.substring(end);
const newPos = start + tagStart.length + selected.length + tagEnd.length;
textarea.setSelectionRange(newPos, newPos);
} else {
const tagStart = '[spoiler]';
textarea.value = val.substring(0, start) + '[spoiler][/spoiler]' + val.substring(start);
const newPos = start + tagStart.length;
textarea.setSelectionRange(newPos, newPos);
}
textarea.focus();
});
const referenceNode = actions.querySelector('.cancel-reply') ||
actions.querySelector('.cancel-edit-btn') ||
actions.querySelector('.submit-comment') ||
actions.querySelector('.save-edit-btn');
actions.insertBefore(trigger, referenceNode);
actions.insertBefore(spoilerBtn, trigger);
// Prevent trigger from blurring the textarea, keeping the keyboard open
trigger.addEventListener('mousedown', (e) => {
e.preventDefault();
});
trigger.addEventListener('touchstart', (e) => {
e.preventDefault();
trigger.click();
}, { passive: false });
if (this.isAdmin) {
const lockBtn = document.createElement('button');
lockBtn.id = 'lock-thread-btn';
lockBtn.title = this.isLocked ? 'Unlock Thread' : 'Lock Thread';
lockBtn.innerHTML = this.isLocked ? this.icons.lock : this.icons.unlock;
lockBtn.className = 'admin-lock-btn';
actions.insertBefore(lockBtn, spoilerBtn);
}
// Create picker once and cache it
let picker = null;
let closeHandler = null;
// ── Shared sticker hold-to-preview overlay ────────────────────────
if (!window.stickerPreview) {
// ── Toast notification (singleton) ──
const toastEl = document.createElement('div');
toastEl.className = 'emoji-fav-toast';
document.body.appendChild(toastEl);
let _toastTimer = null;
const showToast = (msg) => {
clearTimeout(_toastTimer);
toastEl.textContent = msg;
toastEl.classList.remove('toast-out');
toastEl.classList.add('toast-in');
_toastTimer = setTimeout(() => {
toastEl.classList.replace('toast-in', 'toast-out');
}, 2000);
};
const overlay = document.createElement('div');
overlay.className = 'sticker-preview-overlay';
document.body.appendChild(overlay);
let _hideTimer = null;
window.stickerPreview = {
isShowing: false,
_autoHideTimer: null,
show(src, isVideo, name, url) {
clearTimeout(this._autoHideTimer);
clearTimeout(_hideTimer);
overlay.innerHTML = '';
const el = isVideo ? document.createElement('video') : document.createElement('img');
el.src = src;
if (isVideo) { el.autoplay = true; el.loop = true; el.muted = true; el.playsInline = true; }
overlay.appendChild(el);
// Fav button — always shown in overlay for easy mobile access
if (name && url && window._emojiPickerFavUtils) {
const isFav = window._emojiPickerFavUtils.has(name);
const favBtn = document.createElement('button');
favBtn.className = 'sticker-preview-fav-btn' + (isFav ? ' is-fav' : '');
favBtn.innerHTML = ' ';
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
window._emojiPickerFavUtils.toggle(name, url);
const nowFav = window._emojiPickerFavUtils.has(name);
// Close preview immediately and show toast
window.stickerPreview.hide();
showToast(nowFav ? 'Emoji added to Favorites' : 'Emoji removed from Favorites');
window._emojiPickerRefresh?.();
});
favBtn.addEventListener('touchend', (e) => { e.preventDefault(); e.stopPropagation(); favBtn.click(); });
overlay.appendChild(favBtn);
}
overlay.classList.add('visible');
this.isShowing = true;
if (isVideo) el.play().catch(() => {});
},
hide() {
clearTimeout(this._autoHideTimer);
this._autoHideTimer = null;
this.isShowing = false;
overlay.classList.remove('visible');
_hideTimer = setTimeout(() => { overlay.innerHTML = ''; }, 200);
},
// Auto-close after delay (used on mobile so user has time to tap the fav button)
scheduleHide(delay) {
clearTimeout(this._autoHideTimer);
this._autoHideTimer = setTimeout(() => {
this._autoHideTimer = null;
this.hide();
}, delay);
}
};
// Expose showToast so fav-tab right-click can use it
window._showEmojiToast = showToast;
// Click/tap overlay background to dismiss
overlay.addEventListener('click', () => window.stickerPreview.hide());
// Desktop safety net: if mouse released outside the sticker, hide preview
// (skipped if a mobile scheduleHide is already pending)
document.addEventListener('mouseup', () => {
if (window.stickerPreview?.isShowing && !window.stickerPreview._autoHideTimer) {
window.stickerPreview.hide();
}
});
}
const buildPickerContent = () => {
if (!picker) return;
picker.innerHTML = '';
const packs = CommentSystem.emojiPacks || [];
const hasPacks = packs.length > 0;
if (!this.customEmojis || Object.keys(this.customEmojis).length === 0) {
picker.innerHTML = 'No emojis found
';
return;
}
// Helper: create img or video element for a given emoji URL
// ── Favorites (singleton utils + context menu) ──────────────
if (!window._emojiPickerFavUtils) {
window._emojiPickerFavUtils = {
KEY: 'f0ck_emoji_favs',
get() { try { return JSON.parse(localStorage.getItem(this.KEY) || '[]'); } catch(e) { return []; } },
has(name) { return this.get().some(f => f.name === name); },
toggle(name, url) {
const favs = this.get();
const idx = favs.findIndex(f => f.name === name);
const action = idx >= 0 ? 'remove' : 'add';
if (idx >= 0) favs.splice(idx, 1); else favs.push({ name, url });
localStorage.setItem(this.KEY, JSON.stringify(favs));
// Sync to DB for logged-in users (fire-and-forget)
const csrf = window.f0ckSession?.csrf_token;
if (csrf) {
fetch('/api/v2/user/emoji-favorites/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ name, url, action })
}).catch(() => {});
}
},
syncFromDB() {
fetch('/api/v2/user/emoji-favorites', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.success && Array.isArray(data.favorites)) {
localStorage.setItem(this.KEY, JSON.stringify(data.favorites));
// Re-render the open picker so DB favorites appear immediately
window._emojiPickerRefresh?.();
}
}).catch(e => console.warn('[EmojiFavs] syncFromDB failed:', e));
}
};
// Seed from DB on first open if logged in
if (window.f0ckSession?.logged_in) {
window._emojiPickerFavUtils.syncFromDB();
}
}
if (!window._favContextMenu) {
const menu = document.createElement('div');
menu.style.cssText = 'position:fixed;z-index:99999;background:var(--dropdown-bg,#222);border:1px solid var(--nav-border-color,#444);border-radius:7px;padding:4px 0;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:none;min-width:180px;';
document.body.appendChild(menu);
window._favContextMenu = {
el: menu,
show(x, y, name, url, onToggle) {
const isFav = window._emojiPickerFavUtils.has(name);
menu.innerHTML = '';
const item = document.createElement('div');
item.style.cssText = 'padding:9px 16px;cursor:pointer;font-size:0.875em;color:var(--white,#fff);display:flex;align-items:center;gap:8px;border-radius:4px;margin:2px 4px;';
item.innerHTML = '' + (isFav ? '★' : '☆') + ' ' + (isFav ? 'Remove from Favorites' : 'Add to Favorites');
item.addEventListener('mouseenter', () => { item.style.background = 'rgba(255,255,255,0.09)'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
item.addEventListener('click', (e) => { e.stopPropagation(); window._emojiPickerFavUtils.toggle(name, url); menu.style.display = 'none'; if (onToggle) onToggle(); });
menu.appendChild(item);
menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.style.display = 'block';
requestAnimationFrame(() => {
const r = menu.getBoundingClientRect();
if (r.right > window.innerWidth) menu.style.left = (x - r.width) + 'px';
if (r.bottom > window.innerHeight) menu.style.top = (y - r.height) + 'px';
});
},
hide() { menu.style.display = 'none'; }
};
document.addEventListener('click', () => window._favContextMenu.hide());
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') window._favContextMenu.hide(); });
}
const makeEmojiEl = (url, name) => {
const isVideo = url && url.endsWith('.webm');
// media element (img or video)
let media;
if (isVideo) {
media = document.createElement('video');
media.src = url;
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.src = url;
media.loading = 'lazy';
}
media.title = `:${name}:`;
media.onerror = () => { media.style.display = 'none'; };
media._stickerSrc = url;
media._stickerIsVideo = isVideo;
// Wrapper div — needed to position the fav badge overlay
const el = document.createElement('div');
el.className = 'ep-sticker-wrap';
el._stickerSrc = url;
el._stickerIsVideo = isVideo;
el.appendChild(media);
// Fav star badge
if (window._emojiPickerFavUtils?.has(name)) {
const badge = document.createElement('i');
badge.className = 'fa-solid fa-star ep-fav-badge';
el.appendChild(badge);
}
// Hold-to-preview (400ms) — on mobile, tap the ★ in the overlay to favorite
let holdTimer = null;
let holdFired = false;
let favHoldFired = false;
let isTouchHold = false;
const startHold = (fromTouch) => {
holdFired = false;
favHoldFired = false;
isTouchHold = !!fromTouch;
holdTimer = setTimeout(() => {
holdFired = true;
window.stickerPreview?.show(url, isVideo, name, url);
}, 800);
};
const cancelHold = () => { clearTimeout(holdTimer); isTouchHold = false; };
const endHold = () => {
clearTimeout(holdTimer);
if (holdFired || favHoldFired) {
holdFired = false;
favHoldFired = false;
el.addEventListener('click', (e) => {
e.stopImmediatePropagation();
e.preventDefault();
}, { once: true, capture: true });
if (!isTouchHold) {
// Desktop: hide immediately on mouse release
window.stickerPreview?.hide();
}
// Mobile: preview stays open until user taps the overlay
} else {
window.stickerPreview?.hide();
}
isTouchHold = false;
};
media.addEventListener('mousedown', () => startHold(false));
media.addEventListener('mouseup', endHold);
media.addEventListener('mouseleave', cancelHold);
media.addEventListener('mouseenter', () => {
if (window.stickerPreview?.isShowing) window.stickerPreview.show(url, isVideo, name, url);
});
media.addEventListener('touchstart', () => startHold(true), { passive: true });
media.addEventListener('touchmove', cancelHold, { passive: true });
media.addEventListener('touchend', endHold);
media.addEventListener('touchcancel', cancelHold);
// Right-click (desktop only) → favorites context menu
media.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
// Never show the custom menu on touch devices
if (navigator.maxTouchPoints > 0) return;
if (holdFired || favHoldFired) return;
window._favContextMenu?.show(e.clientX, e.clientY, name, url, () => {
showTab(activeTabId);
});
});
return el;
};
if (!hasPacks) {
// Flat list — old behaviour for sites without packs
picker.classList.remove('has-tabs');
Object.keys(this.customEmojis).forEach(name => {
const url = this.customEmojis[name];
const el = makeEmojiEl(url, name);
el.onclick = (ev) => {
ev.stopPropagation();
const pos = textarea.selectionStart ?? textarea.value.length;
const val = textarea.value;
textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
textarea.focus();
const newPos = pos + name.length + 2;
textarea.setSelectionRange(newPos, newPos);
};
picker.appendChild(el);
});
return;
}
// ── Tabbed picker with sticker packs ──
picker.classList.add('has-tabs');
// Tab bar
const tabBar = document.createElement('div');
tabBar.className = 'emoji-picker-tabs';
// Emoji grid area
const gridArea = document.createElement('div');
gridArea.className = 'emoji-picker-grid';
let activeTabId = null;
const showTab = (packId) => {
activeTabId = packId;
window._emojiPickerRefresh = () => showTab(activeTabId);
tabBar.querySelectorAll('.ep-tab').forEach(t => t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null')));
gridArea.innerHTML = '';
gridArea.classList.toggle('favs-active', packId === '__favs__');
// ── Favorites tab ──
if (packId === '__favs__') {
const favs = window._emojiPickerFavUtils?.get() || [];
if (!favs.length) {
const empty = document.createElement('div');
empty.style.cssText = 'grid-column:1/-1;text-align:center;padding:28px 12px;opacity:0.45;font-size:0.82em;line-height:1.5;';
empty.textContent = 'No favorites yet. Right-click any sticker to add it here.';
gridArea.appendChild(empty);
} else {
favs.forEach(({ name, url }) => {
const el = makeEmojiEl(url, name);
el.onclick = (ev) => {
ev.stopPropagation();
const pos = textarea.selectionStart ?? textarea.value.length;
const val = textarea.value;
textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
textarea.focus();
const newPos = pos + name.length + 2;
textarea.setSelectionRange(newPos, newPos);
};
gridArea.appendChild(el);
});
}
return;
}
const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0];
if (!pack) return;
pack.emojis.forEach(({ name, url }) => {
const el = makeEmojiEl(url, name);
el.onclick = (ev) => {
ev.stopPropagation();
const pos = textarea.selectionStart ?? textarea.value.length;
const val = textarea.value;
textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
textarea.focus();
const newPos = pos + name.length + 2;
textarea.setSelectionRange(newPos, newPos);
};
gridArea.appendChild(el);
});
// Touch slide: switch preview immediately as finger moves over a new sticker
gridArea.addEventListener('touchmove', (e) => {
if (!window.stickerPreview?.isShowing) return;
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target && target !== gridArea && (target.tagName === 'IMG' || target.tagName === 'VIDEO') && target._stickerSrc) {
window.stickerPreview.show(target._stickerSrc, target._stickerIsVideo);
}
}, { passive: true });
};
// Helper: reliable touch handling for tab buttons on mobile
const addTabTouch = (btn, cb) => {
let ts = null;
btn.addEventListener('touchstart', (e) => {
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchend', (e) => {
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
};
// Build tab buttons — Favorites first
const favTab = document.createElement('button');
favTab.className = 'ep-tab ep-tab-favs';
favTab.dataset.packId = '__favs__';
favTab.title = 'Favorites';
favTab.type = 'button';
favTab.innerHTML = ' ';
favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'));
// Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
favTab.classList.add('ep-tab-default');
}
tabBar.appendChild(favTab);
packs.forEach((pack, i) => {
const tab = document.createElement('button');
tab.className = 'ep-tab';
tab.dataset.packId = String(pack.id ?? null);
tab.title = pack.name || 'Emojis';
tab.type = 'button';
// Tab icon — always use a static img (thumb_url from import, or first non-video emoji)
if (pack.emojis && pack.emojis.length > 0) {
const iconUrl = pack.thumb_url
|| (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url;
const icon = document.createElement('img');
icon.src = iconUrl;
icon.alt = pack.name;
icon.loading = 'lazy';
tab.appendChild(icon);
} else {
tab.textContent = '\u263A';
}
tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showTab(pack.id ?? null));
addTabTouch(tab, () => showTab(pack.id ?? null));
tabBar.appendChild(tab);
});
picker.appendChild(tabBar);
picker.appendChild(gridArea);
// Show default tab (favorites if set, otherwise first pack)
const defaultTab = localStorage.getItem('f0ck_emoji_default_tab');
showTab(defaultTab === '__favs__' ? '__favs__' : (packs[0]?.id ?? null));
};
// Helper: close picker with the out-animation, then hide
const closePicker = () => {
if (!picker || picker.style.display === 'none') return;
trigger.classList.remove('is-active');
picker.classList.add('picker-closing');
picker.addEventListener('animationend', () => {
picker.style.display = 'none';
picker.classList.remove('picker-closing');
}, { once: true });
};
trigger.addEventListener('click', (e) => {
e.preventDefault();
// Always kick off a load on first click (lazy — noop if already loading/cached)
if (!CommentSystem.emojiCache && !CommentSystem.loadingEmojis) {
this.loadEmojis(true);
// onReady listener (set up during picker creation) handles populating
}
// If picker already exists, toggle visibility
if (picker) {
const isVisible = picker.style.display !== 'none';
if (isVisible) {
closePicker();
} else {
buildPickerContent();
picker.style.display = '';
// Re-trigger enter animation
picker.classList.remove('picker-closing');
picker.style.animation = 'none';
picker.offsetHeight; // reflow
picker.style.animation = '';
trigger.classList.add('is-active');
requestAnimationFrame(() => requestAnimationFrame(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
));
}
return;
}
// Create picker only once
picker = document.createElement('div');
picker.className = 'emoji-picker';
trigger.classList.add('is-active');
// Prevent picker interactions from blurring the textarea, keeping the keyboard open
picker.addEventListener('mousedown', (e) => {
e.preventDefault();
});
// Differentiate touch-scrolling from tap-to-select
let touchStartX = 0;
let touchStartY = 0;
let touchMoved = false;
picker.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchMoved = false;
}, { passive: true });
picker.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
const diffX = Math.abs(touch.clientX - touchStartX);
const diffY = Math.abs(touch.clientY - touchStartY);
if (diffX > 10 || diffY > 10) {
touchMoved = true;
}
}, { passive: true });
picker.addEventListener('touchend', (e) => {
if (touchMoved) {
return; // Scroll gesture - let browser handle naturally
}
// Quick tap - prevent blur to keep keyboard open and click emoji
e.preventDefault();
const img = e.target.closest('img');
if (img) {
img.click();
}
}, { passive: false });
if (CommentSystem.emojiCache) {
// Emojis already cached — populate immediately
buildPickerContent();
} else {
// Show a loading indicator while fetch is in-flight
picker.innerHTML = 'Loading...
';
// Rebuild once the fetch completes (loadEmojis was already triggered above)
const onReady = () => {
window.removeEventListener('f0ck:emojis_ready', onReady);
buildPickerContent();
};
window.addEventListener('f0ck:emojis_ready', onReady, { once: true });
}
container.appendChild(picker);
requestAnimationFrame(() => requestAnimationFrame(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
));
});
}
}
}
// Initial load
window.commentSystem = new CommentSystem();
// Re-init on navigation
document.addEventListener('f0ck:contentLoaded', () => {
if (window.commentSystem && typeof window.commentSystem.destroy === 'function') {
window.commentSystem.destroy();
}
window.commentSystem = new CommentSystem();
});
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
// Looking at f0ck.js, it seems to just replace innerHTML.