-
+
+ ${counter}
${parentId ? `` : ''}
@@ -1980,6 +2052,25 @@ class CommentSystem {
}
});
+ // 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') {
@@ -2036,6 +2127,24 @@ class CommentSystem {
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;
@@ -2353,6 +2462,11 @@ class CommentSystem {
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');
+ }
}
// Notify the right sidebar that a new comment was posted (silent refresh)
@@ -2453,6 +2567,7 @@ class CommentSystem {
const commentEl = tmp.firstElementChild;
if (commentEl) {
repliesEl.appendChild(commentEl);
+ this._ensureTruncationButton(commentEl, newComment.content);
requestAnimationFrame(() => {
commentEl.classList.add('comment-entering', 'new-item-fade');
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -2482,6 +2597,7 @@ class CommentSystem {
} else {
list.appendChild(commentEl);
}
+ this._ensureTruncationButton(commentEl, newComment.content);
requestAnimationFrame(() => {
commentEl.classList.add('comment-entering', 'new-item-fade');
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -2520,6 +2636,30 @@ class CommentSystem {
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 (btn) {
btn.classList.remove('loading');
@@ -2532,6 +2672,7 @@ class CommentSystem {
}
}
+
// 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() {
@@ -2874,7 +3015,7 @@ class CommentSystem {
};
const trigger = document.createElement('button');
- trigger.innerText = '☺';
+ trigger.innerHTML = '
';
trigger.className = 'emoji-trigger';
const actions = container.querySelector('.input-actions');
@@ -2905,15 +3046,16 @@ class CommentSystem {
}
textarea.focus();
});
- actions.prepend(trigger);
- actions.prepend(spoilerBtn);
+ const submitBtn = actions.querySelector('.submit-comment');
+ actions.insertBefore(trigger, submitBtn);
+ actions.insertBefore(spoilerBtn, trigger);
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.prepend(lockBtn);
+ actions.insertBefore(lockBtn, spoilerBtn);
}
// Create picker once and cache it
diff --git a/public/s/js/sanitizer.js b/public/s/js/sanitizer.js
index a4abe74..5bd9ebe 100644
--- a/public/s/js/sanitizer.js
+++ b/public/s/js/sanitizer.js
@@ -3,10 +3,12 @@
* Protects against XSS by stripping disallowed tags and attributes.
*/
class Sanitizer {
- // F-009 Security: Removed form elements (textarea, button, input, label, select, option)
- // to prevent phishing via user-generated content (comments, DMs, chat).
+ // F-009 Security: Removed most form elements (textarea, input, label, select, option)
+ // to prevent phishing via user-generated content. 'button' is allowed because our own
+ // UI injects