Haven/public/js/modules/app-utilities.js

2047 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export default {
// ── Utilities ─────────────────────────────────────────
/** Sanitize a CSS color value only allow hex (#RGB / #RRGGBB), rgb(), hsl(), or CSS variables */
_safeColor(c, fallback = '') {
if (typeof c !== 'string') return fallback;
const s = c.trim();
if (/^#[0-9a-fA-F]{3,6}$/.test(s)) return s;
if (/^(rgb|hsl)a?\([0-9,\s.%]+\)$/.test(s)) return s;
if (/^var\(--[a-zA-Z0-9-]+\)$/.test(s)) return s;
return fallback;
},
_isImageUrl(str) {
if (!str) return false;
const trimmed = str.trim();
if (trimmed.startsWith('e2e-img:')) return true;
if (/^\/uploads\/[\w\-]+\.(jpg|jpeg|png|gif|webp)$/i.test(trimmed)) return true;
if (/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)(\?[^"'<>]*)?$/i.test(trimmed)) return true;
// GIPHY GIF URLs (may not have file extensions)
if (/^https:\/\/media\d*\.giphy\.com\/.+/i.test(trimmed)) return true;
return false;
},
_highlightSearch(escapedHtml, query) {
if (!query) return escapedHtml;
const safeQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return escapedHtml.replace(new RegExp(`(${safeQuery})`, 'gi'), '<mark>$1</mark>');
},
// Returns true when the raw message consists only of emoji
// (Unicode emoji and/or :custom: tokens) plus optional whitespace.
// Capped at 27 to avoid jumbo-sizing a wall of emoji.
_isEmojiOnly(str) {
if (!str || !str.trim()) return false;
const customMatches = str.match(/:([a-zA-Z0-9_-]+):/g) || [];
// Only expand custom tokens that actually exist as loaded emojis
const resolvedCustom = customMatches.filter(m => {
const name = m.slice(1, -1).toLowerCase();
return this.customEmojis && this.customEmojis.some(e => e.name === name);
});
let s = str.replace(/:([a-zA-Z0-9_-]+):/g, ' ');
try {
// Strip unicode emoji, modifiers, ZWJ, variation selectors, flags
s = s.replace(/[\p{Extended_Pictographic}\u{FE00}-\u{FEFF}\u{200D}\u{20E3}\u{1F1E0}-\u{1F1FF}]/gu, '');
} catch {
s = s.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}\u{200D}\u{20E3}]/gu, '');
}
if (s.trim().length > 0) return false;
let unicodeCount = 0;
try { unicodeCount = (str.match(/[\p{Extended_Pictographic}]/gu) || []).length; } catch {}
const total = resolvedCustom.length + unicodeCount;
return total >= 1 && total <= 27;
},
_formatContent(str) {
// E2E encrypted image: e2e-img:<mime>:<url>
const e2eImgMatch = str.match(/^e2e-img:(image\/(?:jpeg|png|gif|webp)):(\/uploads\/[\w\-.]+)$/i);
if (e2eImgMatch) {
const mime = this._escapeHtml(e2eImgMatch[1]);
const url = this._escapeHtml(e2eImgMatch[2]);
return `<img data-e2e-src="${url}" data-e2e-mime="${mime}" class="chat-image e2e-img-pending" alt="Encrypted image" title="🔒 End-to-end encrypted image">`;
}
// Decode legacy HTML entities from old server-side sanitization.
// The server no longer entity-encodes, but older messages in the DB
// may still contain entities like &#39; &amp; &lt; etc.
const emojiOnly = this._isEmojiOnly(str);
str = this._decodeHtmlEntities(str);
// Render file attachments [file:name](url|size)
const fileMatch = str.match(/^\[file:(.+?)\]\((.+?)\|(.+?)\)$/);
if (fileMatch) {
const fileName = this._escapeHtml(fileMatch[1]);
const fileUrl = this._escapeHtml(fileMatch[2]);
const fileSize = this._escapeHtml(fileMatch[3]);
const ext = fileName.split('.').pop().toLowerCase();
const icon = { pdf: '📄', zip: '📦', '7z': '📦', rar: '📦', tar: '📦', gz: '📦',
mp3: '🎵', ogg: '🎵', wav: '🎵', flac: '🎵', aac: '🎵', wma: '🎵',
mp4: '🎬', webm: '🎬', mkv: '🎬', avi: '🎬', mov: '🎬', flv: '🎬',
doc: '📝', docx: '📝', xls: '📊', xlsx: '📊', ppt: '📊', pptx: '📊',
txt: '📄', csv: '📄', json: '📄', md: '📄', log: '📄',
exe: '⚙️', msi: '⚙️', bat: '⚙️', cmd: '⚙️', ps1: '⚙️', sh: '⚙️',
dll: '⚙️', iso: '💿', dmg: '💿', img: '💿',
apk: '📱', deb: '📦', rpm: '📦',
py: '🐍', js: '📜', ts: '📜', html: '🌐', css: '🎨', svg: '🖼️' }[ext] || '📎';
const RISKY_EXTS = new Set([
'exe','bat','cmd','com','scr','pif','msi','msp','mst',
'ps1','vbs','vbe','js','jse','wsf','wsh','hta',
'cpl','inf','reg','dll','ocx','sys','drv',
'sh','app','dmg','pkg','deb','rpm','appimage',
]);
// Audio/video get inline players
if (['mp3', 'ogg', 'wav'].includes(ext)) {
return `<div class="file-attachment">
<div class="file-info">${icon} <span class="file-name">${fileName}</span> <span class="file-size">(${fileSize})</span></div>
<audio controls preload="none" src="${fileUrl}"></audio>
</div>`;
}
if (['mp4', 'webm'].includes(ext)) {
return `<div class="file-attachment">
<div class="file-info">${icon} <span class="file-name">${fileName}</span> <span class="file-size">(${fileSize})</span></div>
<div class="file-video-wrap">
<video controls preload="none" src="${fileUrl}" class="file-video"></video>
</div>
</div>`;
}
return `<div class="file-attachment">
<a href="${fileUrl}" target="_blank" rel="noopener noreferrer" class="file-download-link${RISKY_EXTS.has(ext) ? ' risky-file' : ''}" download="${fileName}"${RISKY_EXTS.has(ext) ? ' data-risky="true"' : ''}>
<span class="file-icon">${icon}</span>
<span class="file-name">${fileName}</span>
<span class="file-size">(${fileSize})</span>
<span class="file-download-arrow">⬇</span>
</a>
</div>`;
}
// Render server-hosted images inline (early return)
// No loading="lazy" — content-visibility:auto on .message already skips off-screen
// rendering; lazy loading on top creates 0→real-height jumps when scrolling history.
if (/^\/uploads\/[\w\-]+\.(jpg|jpeg|png|gif|webp)$/i.test(str.trim())) {
return `<img src="${this._escapeHtml(str.trim())}" class="chat-image" alt="image">`;
}
// ── Extract fenced code blocks before escaping ──
const codeBlocks = [];
const withPlaceholders = str.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const idx = codeBlocks.length;
codeBlocks.push({ lang: lang || '', code });
return `\x00CODEBLOCK_${idx}\x00`;
});
let html = this._escapeHtml(withPlaceholders);
// ── Markdown images & links (extract before auto-linking) ──
const mdLinks = [];
// ![alt](url)
html = html.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (full, alt, url) => {
try { new URL(url); } catch { return full; }
const safeUrl = url.replace(/['"<>]/g, '');
const idx = mdLinks.length;
mdLinks.push(`<img src="${safeUrl}" class="chat-image" alt="${alt || 'image'}">`);
return `\x00MDLINK_${idx}\x00`;
});
// [text](url)
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (full, text, url) => {
try { new URL(url); } catch { return full; }
const safeUrl = url.replace(/['"<>]/g, '');
const idx = mdLinks.length;
mdLinks.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" title="${safeUrl}" data-masked-link="true">${text}</a>`);
return `\x00MDLINK_${idx}\x00`;
});
// Auto-link URLs (and render image URLs as inline images)
// Use placeholders to prevent @mention regex from matching inside URLs
const autoLinks = [];
html = html.replace(
/\bhttps?:\/\/[a-zA-Z0-9\-._~:/?#\[\]@!$&()*+,;=%]+/g,
(url) => {
try { new URL(url); } catch { return url; }
const safeUrl = url.replace(/['"<>]/g, '');
const idx = autoLinks.length;
if (/\.(jpg|jpeg|png|gif|webp)(\?[^"'<>]*)?$/i.test(safeUrl) ||
/^https:\/\/media\d*\.giphy\.com\//i.test(safeUrl)) {
autoLinks.push(`<img src="${safeUrl}" class="chat-image" alt="image" loading="lazy">`);
} else {
autoLinks.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${safeUrl}</a>`);
}
return `\x00AUTOLINK_${idx}\x00`;
}
);
// Render @mentions with highlight (negative lookbehind prevents matching inside email addresses)
html = html.replace(/(?<!\w)@(\w{1,30})/g, (match, username) => {
const isSelf = username.toLowerCase() === this.user.username.toLowerCase();
return `<span class="mention${isSelf ? ' mention-self' : ''}">${match}</span>`;
});
// Render spoilers (||text||) — CSP-safe, uses delegated click handler
html = html.replace(/\|\|(.+?)\|\|/g, '<span class="spoiler">$1</span>');
// Render custom emojis :name:
if (this.customEmojis && this.customEmojis.length > 0) {
html = html.replace(/:([a-zA-Z0-9_-]+):/g, (match, name) => {
const emoji = this.customEmojis.find(e => e.name === name.toLowerCase());
if (emoji) return `<img src="${this._escapeHtml(emoji.url)}" alt=":${this._escapeHtml(name)}:" title=":${this._escapeHtml(name)}:" class="custom-emoji">`;
return match;
});
}
// Render /me action text (italic)
if (html.startsWith('_') && html.endsWith('_') && html.length > 2) {
html = `<em class="action-text">${html.slice(1, -1)}</em>`;
}
// Render **bold**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Render *italic*
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// Render ~~strikethrough~~
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Render ==highlight==
html = html.replace(/==(.+?)==/g, '<mark class="chat-highlight">$1</mark>');
// Render `inline code`
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Render grouped > blockquotes and preserve attribution lines inside the quote.
const blockquotes = [];
html = html.replace(/(^|\n)((?:&gt;[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
const lines = block.trim().split('\n').map(line => line.replace(/^&gt;\s?/, ''));
let authorHtml = '';
if (lines[0] && /^@[^\s].+ wrote:$/.test(lines[0])) {
authorHtml = `<div class="chat-blockquote-author">${lines.shift()}</div>`;
}
const textHtml = lines.join('<br>');
const idx = blockquotes.length;
blockquotes.push(`${pre}<blockquote class="chat-blockquote">${authorHtml}<div class="chat-blockquote-body">${textHtml}</div></blockquote>`);
return `\x00BLOCKQUOTE_${idx}\x00`;
});
// ── Headings: # H1, ## H2, ### H3 at start of line ──
html = html.replace(/(^|\n)(#{1,3})\s+(.+)/g, (_, pre, hashes, text) => {
const level = hashes.length;
return `${pre}<div class="chat-heading chat-h${level}">${text}</div>`;
});
// ── Horizontal rules: --- or ___ on their own line (3+ chars) ──
html = html.replace(/(^|\n)([-]{3,}|[_]{3,})\s*(?=\n|$)/g, '$1<hr class="chat-hr">');
// ── Unordered lists: consecutive lines starting with "- " ──
html = html.replace(/((?:(?:^|\n)- .+)+)/g, (match) => {
const items = match.trim().split('\n').map(line =>
`<li>${line.replace(/^- /, '')}</li>`
).join('');
return `\n<ul class="chat-list">${items}</ul>`;
});
// ── Ordered lists: consecutive lines starting with "N. " ──
html = html.replace(/((?:(?:^|\n)\d+\.\s+.+)+)/g, (match) => {
const lines = match.trim().split('\n');
const startNum = lines[0].match(/^(\d+)/)?.[1] || '1';
const items = lines.map(line =>
`<li>${line.replace(/^\d+\.\s+/, '')}</li>`
).join('');
return `\n<ol class="chat-list" start="${startNum}">${items}</ol>`;
});
html = html.replace(/\n/g, '<br>');
blockquotes.forEach((block, idx) => {
html = html.replace(`\x00BLOCKQUOTE_${idx}\x00`, block);
});
// ── Restore fenced code blocks ──
codeBlocks.forEach((block, idx) => {
const escaped = this._escapeHtml(block.code).replace(/\n$/, '');
const langAttr = block.lang ? ` data-lang="${this._escapeHtml(block.lang)}"` : '';
const langLabel = block.lang ? `<span class="code-block-lang">${this._escapeHtml(block.lang)}</span>` : '';
const rendered = `<div class="code-block"${langAttr}>${langLabel}<pre><code>${escaped}</code></pre></div>`;
html = html.replace(`\x00CODEBLOCK_${idx}\x00`, rendered);
});
// ── Restore markdown links/images ──
mdLinks.forEach((link, idx) => {
html = html.replace(`\x00MDLINK_${idx}\x00`, link);
});
// ── Restore auto-linked URLs ──
autoLinks.forEach((link, idx) => {
html = html.replace(`\x00AUTOLINK_${idx}\x00`, link);
});
if (emojiOnly) html = `<span class="emoji-only-msg">${html}</span>`;
return html;
},
_formatTime(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
if (isToday) return t('utils.today_at', { time });
if (isYesterday) return t('utils.yesterday_at', { time });
return `${date.toLocaleDateString()} ${time}`;
},
_getUserColor(username) {
const colors = [
'#e94560', '#7c5cfc', '#43b581', '#faa61a',
'#f47fff', '#00b8d4', '#ff6b6b', '#a8e6cf',
'#82aaff', '#c792ea', '#ffcb6b', '#89ddff'
];
let hash = 0;
for (const ch of username) {
hash = ((hash << 5) - hash) + ch.charCodeAt(0);
}
return colors[Math.abs(hash) % colors.length];
},
_isScrolledToBottom() {
const el = document.getElementById('messages');
return el.scrollHeight - el.clientHeight - el.scrollTop < 150;
},
_scrollToBottom(force) {
const el = document.getElementById('messages');
if (force || this._coupledToBottom) {
el.scrollTop = el.scrollHeight;
}
},
_showToast(message, type = 'info', action = null, duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
if (duration !== 4000) {
const fadeStart = (duration - 300) / 1000;
toast.style.animation = `toastIn 0.25s ease, toastOut 0.3s ease ${fadeStart}s forwards`;
}
if (action) {
toast.style.display = 'flex';
toast.style.alignItems = 'center';
toast.style.gap = '10px';
const span = document.createElement('span');
span.style.flex = '1';
span.textContent = message;
toast.appendChild(span);
const btn = document.createElement('button');
btn.className = 'toast-action-btn';
btn.textContent = action.label;
btn.addEventListener('click', () => { action.onClick(); toast.remove(); });
toast.appendChild(btn);
} else {
toast.textContent = message;
}
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
},
/** Show a one-time notice about the Account Recovery feature */
_showRecoveryNotice() {
// Guard: only show once
if (localStorage.getItem('haven_recovery_notice_v1')) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay recovery-notice-overlay';
overlay.style.cssText = 'display:flex;z-index:9999';
overlay.innerHTML = `
<div class="modal" style="max-width:400px">
<h3>🔑 ${t('modals.recovery_notice.title')}</h3>
<p class="modal-desc" style="margin-bottom:12px">${t('modals.recovery_notice.body')}</p>
<div style="background:rgba(231,76,60,0.12);border:1px solid rgba(231,76,60,0.4);border-radius:8px;padding:10px 12px;margin-bottom:14px;font-size:0.83rem;color:var(--text-secondary)">
⚠️ ${t('modals.recovery_notice.warning')}
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:0.85rem;color:var(--text-muted);margin-bottom:14px;cursor:pointer">
<input type="checkbox" id="recovery-notice-dsa">
<span>${t('modals.recovery_notice.dsa')}</span>
</label>
<div class="modal-actions">
<button class="btn-primary" id="recovery-notice-go">${t('modals.recovery_notice.go_btn')}</button>
<button class="btn-sm" id="recovery-notice-close" style="padding:8px 18px">${t('modals.common.dismiss')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const dismiss = () => {
if (document.getElementById('recovery-notice-dsa')?.checked) {
localStorage.setItem('haven_recovery_notice_v1', '1');
}
overlay.remove();
};
document.getElementById('recovery-notice-close').addEventListener('click', dismiss);
document.getElementById('recovery-notice-go').addEventListener('click', () => {
if (document.getElementById('recovery-notice-dsa')?.checked) {
localStorage.setItem('haven_recovery_notice_v1', '1');
}
overlay.remove();
// Open settings modal and navigate to recovery section
document.getElementById('open-settings-btn')?.click();
setTimeout(() => {
const navItem = document.querySelector('.settings-nav-item[data-target="section-recovery"]');
if (navItem) navItem.click();
}, 150);
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) dismiss(); });
},
/** Warn users before downloading potentially harmful file types */
_showExternalLinkWarning(displayText, url) {
document.querySelector('.risky-download-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'risky-download-overlay';
overlay.innerHTML = `
<div class="risky-download-modal">
<div class="risky-download-icon">🔗</div>
<h3 style="color:var(--text-primary,#dbdee1)">External Link</h3>
<p>You're about to visit:</p>
<p style="background:var(--bg-tertiary,#232428);padding:8px 12px;border-radius:6px;font-size:13px;word-break:break-all;color:var(--accent,#5865f2)">${this._escapeHtml(url)}</p>
<p class="risky-download-desc">Make sure you trust this link before continuing.</p>
<div class="risky-download-actions">
<button class="risky-download-cancel">Cancel</button>
<button class="risky-download-confirm" style="background:var(--accent,#5865f2)">Open Link</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('.risky-download-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('.risky-download-confirm').addEventListener('click', () => {
overlay.remove();
window.open(url, '_blank', 'noopener,noreferrer');
});
},
_showRiskyDownloadWarning(fileName, ext, url) {
// Remove any existing warning overlay
document.querySelector('.risky-download-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'risky-download-overlay';
overlay.innerHTML = `
<div class="risky-download-modal">
<div class="risky-download-icon">⚠️</div>
<h3>Potentially Harmful File</h3>
<p><strong>${this._escapeHtml(fileName)}</strong></p>
<p class="risky-download-desc">
<strong>.${this._escapeHtml(ext)}</strong> files can be dangerous and may harm your
device if they come from an untrusted source. Only download this if
you trust the sender.
</p>
<div class="risky-download-actions">
<button class="risky-download-cancel">Cancel</button>
<button class="risky-download-confirm">Download Anyway</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Cancel
overlay.querySelector('.risky-download-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Confirm download
overlay.querySelector('.risky-download-confirm').addEventListener('click', () => {
overlay.remove();
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
a.remove();
});
},
// ═══════════════════════════════════════════════════════
// EMOJI PICKER (categorized + searchable)
// ═══════════════════════════════════════════════════════
_toggleEmojiPicker() {
const picker = document.getElementById('emoji-picker');
if (picker.style.display === 'flex') {
picker.style.display = 'none';
return;
}
picker.innerHTML = '';
this._emojiActiveCategory = this._emojiActiveCategory || Object.keys(this.emojiCategories)[0];
// Search bar
const searchRow = document.createElement('div');
searchRow.className = 'emoji-search-row';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'emoji-search-input';
searchInput.placeholder = t('emoji.search_placeholder');
searchInput.maxLength = 30;
searchRow.appendChild(searchInput);
picker.appendChild(searchRow);
// Build combined categories (standard + custom)
const allCategories = { ...this.emojiCategories };
const hasCustom = this.customEmojis && this.customEmojis.length > 0;
if (hasCustom) {
allCategories['Custom'] = this.customEmojis.map(e => `:${e.name}:`);
}
// Category tabs
const tabRow = document.createElement('div');
tabRow.className = 'emoji-tab-row';
const catIcons = { 'Smileys':'😀', 'People':'👋', 'Animals':'🐶', 'Food':'🍕', 'Activities':'🎮', 'Travel':'🚀', 'Objects':'💡', 'Symbols':'❤️', 'Custom':'⭐' };
for (const cat of Object.keys(allCategories)) {
const tab = document.createElement('button');
tab.className = 'emoji-tab' + (cat === this._emojiActiveCategory ? ' active' : '');
tab.textContent = catIcons[cat] || cat.charAt(0);
tab.title = t(`emoji.categories.${cat.toLowerCase()}`) || cat;
tab.addEventListener('click', () => {
this._emojiActiveCategory = cat;
searchInput.value = '';
renderGrid();
tabRow.querySelectorAll('.emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
});
tabRow.appendChild(tab);
}
picker.appendChild(tabRow);
// Grid
const grid = document.createElement('div');
grid.className = 'emoji-grid';
picker.appendChild(grid);
const self = this;
function renderGrid(filter) {
grid.innerHTML = '';
let emojis;
if (filter) {
const q = filter.toLowerCase();
const matched = new Set();
// Search by emoji name keywords
for (const [emoji, keywords] of Object.entries(self.emojiNames)) {
if (keywords.toLowerCase().includes(q)) matched.add(emoji);
}
// Also search by category name
for (const [cat, list] of Object.entries(self.emojiCategories)) {
if (cat.toLowerCase().includes(q)) list.forEach(e => matched.add(e));
}
// Search custom emojis by name
if (self.customEmojis) {
self.customEmojis.forEach(e => {
if (e.name.toLowerCase().includes(q)) matched.add(`:${e.name}:`);
});
}
emojis = matched.size > 0 ? [...matched] : [];
} else {
emojis = allCategories[self._emojiActiveCategory] || self.emojis;
}
if (filter && emojis.length === 0) {
grid.innerHTML = `<p class="muted-text" style="padding:12px;font-size:12px;width:100%;text-align:center">${t('emoji.no_results')}</p>`;
return;
}
emojis.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'emoji-item';
// Check if it's a custom emoji (:name:)
const customMatch = typeof emoji === 'string' && emoji.match(/^:([a-zA-Z0-9_-]+):$/);
if (customMatch) {
const ce = self.customEmojis.find(e => e.name === customMatch[1]);
if (ce) {
btn.innerHTML = `<img src="${self._escapeHtml(ce.url)}" alt=":${self._escapeHtml(ce.name)}:" title=":${self._escapeHtml(ce.name)}:" class="custom-emoji">`;
} else {
btn.textContent = emoji;
}
} else {
btn.textContent = emoji;
}
btn.addEventListener('click', () => {
// Insert into the active edit textarea if editing, otherwise the main input
const input = self._activeEditTextarea || document.getElementById('message-input');
const start = input.selectionStart;
const end = input.selectionEnd;
input.value = input.value.substring(0, start) + emoji + input.value.substring(end);
input.selectionStart = input.selectionEnd = start + emoji.length;
input.focus();
});
grid.appendChild(btn);
});
}
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
renderGrid(q || null);
});
renderGrid();
// On mobile with the iOS keyboard open, dynamically position the picker
// above the input area using the visual viewport so it doesn't push
// content off-screen.
if (window.innerWidth <= 480 && window.visualViewport) {
const vvHeight = window.visualViewport.height;
const inputArea = document.getElementById('message-input-area');
if (inputArea) {
const inputRect = inputArea.getBoundingClientRect();
picker.style.bottom = (window.innerHeight - inputRect.top) + 'px';
}
}
picker.style.display = 'flex';
searchInput.focus();
},
// ═══════════════════════════════════════════════════════
// GIF PICKER (GIPHY)
// ═══════════════════════════════════════════════════════
_setupGifPicker() {
const btn = document.getElementById('gif-btn');
const picker = document.getElementById('gif-picker');
const searchInput = document.getElementById('gif-search-input');
const grid = document.getElementById('gif-grid');
if (!btn || !picker) return;
this._gifDebounce = null;
btn.addEventListener('click', () => {
if (picker.style.display === 'flex') {
picker.style.display = 'none';
return;
}
// Close emoji picker if open
document.getElementById('emoji-picker').style.display = 'none';
picker.style.display = 'flex';
searchInput.value = '';
searchInput.focus();
this._loadTrendingGifs();
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (picker.style.display !== 'none' &&
!picker.contains(e.target) && !btn.contains(e.target)) {
picker.style.display = 'none';
}
});
// Search on typing with debounce
searchInput.addEventListener('input', () => {
clearTimeout(this._gifDebounce);
const q = searchInput.value.trim();
if (!q) {
this._loadTrendingGifs();
return;
}
this._gifDebounce = setTimeout(() => this._searchGifs(q), 350);
});
// Click on a GIF to send it
grid.addEventListener('click', (e) => {
const img = e.target.closest('img');
if (!img || !img.dataset.full) return;
this._sendGifMessage(img.dataset.full);
picker.style.display = 'none';
});
},
_loadTrendingGifs() {
const grid = document.getElementById('gif-grid');
grid.innerHTML = '<div class="gif-picker-empty">Loading...</div>';
fetch('/api/gif/trending?limit=20', {
headers: { 'Authorization': `Bearer ${this.token}` }
})
.then(r => r.json())
.then(data => {
if (data.error === 'gif_not_configured') {
this._showGifSetupGuide(grid);
return;
}
if (data.error) {
grid.innerHTML = `<div class="gif-picker-empty">${this._escapeHtml(data.error)}</div>`;
return;
}
this._renderGifGrid(data.results || []);
})
.catch(() => {
grid.innerHTML = '<div class="gif-picker-empty">Failed to load GIFs</div>';
});
},
_searchGifs(query) {
const grid = document.getElementById('gif-grid');
grid.innerHTML = `<div class="gif-picker-empty">${t('gifs.searching')}</div>`;
fetch(`/api/gif/search?q=${encodeURIComponent(query)}&limit=20`, {
headers: { 'Authorization': `Bearer ${this.token}` }
})
.then(r => r.json())
.then(data => {
if (data.error === 'gif_not_configured') {
this._showGifSetupGuide(grid);
return;
}
if (data.error) {
grid.innerHTML = `<div class="gif-picker-empty">${this._escapeHtml(data.error)}</div>`;
return;
}
const results = data.results || [];
if (results.length === 0) {
grid.innerHTML = `<div class="gif-picker-empty">${t('gifs.no_results')}</div>`;
return;
}
this._renderGifGrid(results);
})
.catch(() => {
grid.innerHTML = `<div class="gif-picker-empty">${t('gifs.search_failed')}</div>`;
});
},
_showGifSetupGuide(grid) {
const isAdmin = this.user && this.user.isAdmin;
if (isAdmin) {
grid.innerHTML = `
<div class="gif-setup-guide">
<h3>🎞️ ${t('gifs.setup.title')}</h3>
<p>${t('gifs.setup.powered_by')}</p>
<ol>
<li>${t('gifs.setup.step_1')}</li>
<li>${t('gifs.setup.step_2')}</li>
<li>${t('gifs.setup.step_3')}</li>
<li>${t('gifs.setup.step_4')}</li>
<li>${t('gifs.setup.step_5')}</li>
</ol>
<div class="gif-setup-input-row">
<input type="text" id="gif-giphy-key-input" placeholder="${t('gifs.setup.key_placeholder')}" spellcheck="false" autocomplete="off" />
<button id="gif-giphy-key-save">${t('gifs.setup.save_btn')}</button>
</div>
<p class="gif-setup-note">💡 ${t('gifs.setup.note')}</p>
</div>`;
const saveBtn = document.getElementById('gif-giphy-key-save');
const input = document.getElementById('gif-giphy-key-input');
saveBtn.addEventListener('click', () => {
const key = input.value.trim();
if (!key) return;
this.socket.emit('update-server-setting', { key: 'giphy_api_key', value: key });
grid.innerHTML = `<div class="gif-picker-empty">${t('gifs.setup.saved')}</div>`;
setTimeout(() => this._loadTrendingGifs(), 500);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveBtn.click();
});
} else {
grid.innerHTML = `
<div class="gif-setup-guide">
<h3>🎞️ ${t('gifs.setup.unavailable_title')}</h3>
<p>${t('gifs.setup.unavailable_desc')}</p>
</div>`;
}
},
_renderGifGrid(results) {
const grid = document.getElementById('gif-grid');
grid.innerHTML = '';
results.forEach(gif => {
if (!gif.tiny) return;
const img = document.createElement('img');
img.src = gif.tiny;
img.alt = gif.title || 'GIF';
img.loading = 'lazy';
img.dataset.full = gif.full || gif.tiny;
grid.appendChild(img);
});
},
_sendGifMessage(url) {
if (!this.currentChannel || !url) return;
const payload = {
code: this.currentChannel,
content: url,
};
if (this.replyingTo) {
payload.replyTo = this.replyingTo.id;
this._clearReply();
}
this.socket.emit('send-message', payload);
this.notifications.play('sent');
},
// /gif slash command — inline GIF search results above the input
_showGifSlashResults(query) {
// Remove any existing picker
document.getElementById('gif-slash-picker')?.remove();
const picker = document.createElement('div');
picker.id = 'gif-slash-picker';
picker.className = 'gif-slash-picker';
picker.innerHTML = '<div class="gif-slash-loading">Searching GIFs...</div>';
// Position above the message input
const inputArea = document.querySelector('.message-input-area');
inputArea.parentElement.insertBefore(picker, inputArea);
// Close on click outside
const closeOnClick = (e) => {
if (!picker.contains(e.target)) { picker.remove(); document.removeEventListener('click', closeOnClick); }
};
setTimeout(() => document.addEventListener('click', closeOnClick), 100);
// Close on Escape
const closeOnEsc = (e) => {
if (e.key === 'Escape') { picker.remove(); document.removeEventListener('keydown', closeOnEsc); }
};
document.addEventListener('keydown', closeOnEsc);
fetch(`/api/gif/search?q=${encodeURIComponent(query)}&limit=12`, {
headers: { 'Authorization': `Bearer ${this.token}` }
})
.then(r => r.json())
.then(data => {
if (data.error === 'gif_not_configured') {
picker.innerHTML = '<div class="gif-slash-loading">GIF search not configured — an admin needs to set up the GIPHY API key (use the GIF button 🎞️)</div>';
return;
}
if (data.error) { picker.innerHTML = `<div class="gif-slash-loading">${this._escapeHtml(data.error)}</div>`; return; }
const results = data.results || [];
if (results.length === 0) { picker.innerHTML = '<div class="gif-slash-loading">No GIFs found</div>'; return; }
picker.innerHTML = `<div class="gif-slash-header"><span>/gif ${this._escapeHtml(query)}</span><button class="icon-btn small gif-slash-close">&times;</button></div><div class="gif-slash-grid"></div>`;
const grid = picker.querySelector('.gif-slash-grid');
picker.querySelector('.gif-slash-close').addEventListener('click', () => picker.remove());
results.forEach(gif => {
if (!gif.tiny) return;
const img = document.createElement('img');
img.src = gif.tiny;
img.alt = gif.title || 'GIF';
img.loading = 'lazy';
img.dataset.full = gif.full || gif.tiny;
img.addEventListener('click', () => {
this._sendGifMessage(img.dataset.full);
picker.remove();
document.removeEventListener('click', closeOnClick);
document.removeEventListener('keydown', closeOnEsc);
});
grid.appendChild(img);
});
})
.catch(() => {
picker.innerHTML = '<div class="gif-slash-loading">GIF search failed</div>';
});
},
// ═══════════════════════════════════════════════════════
// POLLS
// ═══════════════════════════════════════════════════════
_renderPollWidget(msgId, poll) {
if (!poll || !poll.question || !Array.isArray(poll.options)) return '';
const votes = poll.votes || {};
const totalVotes = poll.totalVotes || 0;
const myId = this.user.id;
const optionsHtml = poll.options.map((opt, i) => {
const voters = votes[i] || [];
const count = voters.length;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
const myVote = voters.some(v => v.user_id === myId);
const voterNames = poll.anonymous ? '' : voters.map(v => this._escapeHtml(v.username)).join(', ');
return `<button class="poll-option${myVote ? ' poll-voted' : ''}" data-msg-id="${msgId}" data-option="${i}" title="${voterNames}">
<div class="poll-option-bar" style="width:${pct}%"></div>
<span class="poll-option-text">${this._escapeHtml(opt)}</span>
<span class="poll-option-count">${count} (${pct}%)</span>
</button>`;
}).join('');
const settings = [];
if (poll.multiVote) settings.push('Multiple votes');
if (poll.anonymous) settings.push('Anonymous');
const settingsHtml = settings.length ? `<div class="poll-settings-info">${settings.join(' · ')}</div>` : '';
return `<div class="poll-widget" data-msg-id="${msgId}">
<div class="poll-question">${this._escapeHtml(poll.question)}</div>
<div class="poll-options">${optionsHtml}</div>
<div class="poll-footer">${totalVotes} vote${totalVotes !== 1 ? 's' : ''}${settingsHtml ? ' · ' : ''}${settingsHtml}</div>
</div>`;
},
_updatePollVotes(messageId, votes, totalVotes) {
const widget = document.querySelector(`.poll-widget[data-msg-id="${messageId}"]`);
if (!widget) return;
const wasAtBottom = this._coupledToBottom;
const myId = this.user.id;
// Get current poll data from the message to know anonymous/multiVote settings
const msgEl = document.querySelector(`[data-msg-id="${messageId}"]`);
const pollAnonymous = msgEl && msgEl.dataset.pollAnonymous === '1';
widget.querySelectorAll('.poll-option').forEach(btn => {
const idx = parseInt(btn.dataset.option);
const voters = votes[idx] || [];
const count = voters.length;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
const myVote = voters.some(v => v.user_id === myId);
btn.classList.toggle('poll-voted', myVote);
btn.title = pollAnonymous ? '' : voters.map(v => this._escapeHtml(v.username)).join(', ');
const bar = btn.querySelector('.poll-option-bar');
if (bar) bar.style.width = pct + '%';
const countEl = btn.querySelector('.poll-option-count');
if (countEl) countEl.textContent = `${count} (${pct}%)`;
});
const footer = widget.querySelector('.poll-footer');
if (footer) {
const settingsInfo = footer.querySelector('.poll-settings-info');
const settingsHtml = settingsInfo ? ' · ' + settingsInfo.outerHTML : '';
footer.innerHTML = `${totalVotes} vote${totalVotes !== 1 ? 's' : ''}${settingsHtml}`;
}
if (wasAtBottom) this._scrollToBottom(true);
},
// ═══════════════════════════════════════════════════════
// REACTIONS
// ═══════════════════════════════════════════════════════
_renderReactions(msgId, reactions) {
if (!reactions || reactions.length === 0) return '';
// Group by emoji
const grouped = {};
reactions.forEach(r => {
if (!grouped[r.emoji]) grouped[r.emoji] = { emoji: r.emoji, users: [] };
grouped[r.emoji].users.push({ id: r.user_id, username: r.username });
});
const badges = Object.values(grouped).map(g => {
const isOwn = g.users.some(u => u.id === this.user.id);
const names = g.users.map(u => u.username).join(', ');
const usersJson = this._escapeHtml(JSON.stringify(g.users.map(u => u.username)));
// Check if it's a custom emoji
const customMatch = g.emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = g.emoji;
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
}
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" data-users="${usersJson}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
}).join('');
return `<div class="reactions-row">${badges}</div>`;
},
_updateMessageReactions(messageId, reactions) {
const msgEl = document.querySelector(`[data-msg-id="${messageId}"]`);
if (!msgEl) return;
const wasAtBottom = this._coupledToBottom;
// Remove old reactions row
const oldRow = msgEl.querySelector('.reactions-row');
if (oldRow) oldRow.remove();
// Add new reactions
const html = this._renderReactions(messageId, reactions);
if (!html) { if (wasAtBottom) this._scrollToBottom(true); return; }
// Find where to insert — after main or thread message content
const content = msgEl.querySelector('.message-content, .thread-msg-content');
if (content) {
content.insertAdjacentHTML('afterend', html);
}
if (wasAtBottom) this._scrollToBottom(true);
},
// ── Reaction popout (who reacted) ─────────────────────
_showReactionPopout(badge) {
this._hideReactionPopout();
let users;
try { users = JSON.parse(badge.dataset.users || '[]'); } catch { return; }
if (!users.length) return;
const emoji = badge.dataset.emoji;
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = emoji;
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
}
const popout = document.createElement('div');
popout.id = 'reaction-popout';
popout.className = 'reaction-popout';
popout.innerHTML = `
<div class="reaction-popout-header">${emojiDisplay} <span class="reaction-popout-count">${users.length}</span></div>
<div class="reaction-popout-list">
${users.map(u => `<div class="reaction-popout-user">${this._escapeHtml(u)}</div>`).join('')}
</div>
`;
document.body.appendChild(popout);
// Position above the badge
const rect = badge.getBoundingClientRect();
popout.style.left = rect.left + 'px';
popout.style.top = (rect.top - popout.offsetHeight - 6) + 'px';
// Clamp to viewport
const pr = popout.getBoundingClientRect();
if (pr.right > window.innerWidth) popout.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.left < 0) popout.style.left = '8px';
if (pr.top < 0) popout.style.top = (rect.bottom + 6) + 'px';
},
_hideReactionPopout() {
const existing = document.getElementById('reaction-popout');
if (existing) existing.remove();
},
_getQuickEmojis() {
const saved = localStorage.getItem('haven_quick_emojis');
if (saved) {
try { const arr = JSON.parse(saved); if (Array.isArray(arr) && arr.length === 8) return arr; } catch {}
}
return ['👍','👎','😂','❤️','🔥','💯','😮','😢'];
},
_saveQuickEmojis(emojis) {
localStorage.setItem('haven_quick_emojis', JSON.stringify(emojis));
},
_showQuickEmojiEditor(picker, msgEl, msgId) {
// Remove any existing editor
document.querySelectorAll('.quick-emoji-editor').forEach(el => el.remove());
const editor = document.createElement('div');
editor.className = 'quick-emoji-editor reaction-full-picker';
const title = document.createElement('div');
title.className = 'reaction-full-category';
title.textContent = t('emoji.customize_quick_title');
editor.appendChild(title);
const hint = document.createElement('p');
hint.className = 'muted-text';
hint.style.cssText = 'font-size:11px;padding:0 8px 6px;margin:0';
hint.textContent = t('emoji.customize_quick_hint');
editor.appendChild(hint);
// Current slots
const current = this._getQuickEmojis();
const slotsRow = document.createElement('div');
slotsRow.className = 'quick-emoji-slots';
let activeSlot = null;
const renderSlots = () => {
slotsRow.innerHTML = '';
current.forEach((emoji, i) => {
const slot = document.createElement('button');
slot.className = 'reaction-pick-btn quick-emoji-slot' + (activeSlot === i ? ' active' : '');
// Check for custom emoji
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) slot.innerHTML = `<img src="${this._escapeHtml(ce.url)}" alt="${this._escapeHtml(emoji)}" class="custom-emoji" style="width:20px;height:20px">`;
else slot.textContent = emoji;
} else {
slot.textContent = emoji;
}
slot.addEventListener('click', (e) => {
e.stopPropagation();
activeSlot = i;
renderSlots();
});
slotsRow.appendChild(slot);
});
};
renderSlots();
editor.appendChild(slotsRow);
// Emoji grid for selection
const grid = document.createElement('div');
grid.className = 'reaction-full-grid';
grid.style.maxHeight = '180px';
const renderOptions = () => {
grid.innerHTML = '';
// Standard emojis
for (const [category, emojis] of Object.entries(this.emojiCategories)) {
const label = document.createElement('div');
label.className = 'reaction-full-category';
label.textContent = t(`emoji.categories.${category.toLowerCase()}`) || category;
grid.appendChild(label);
const row = document.createElement('div');
row.className = 'reaction-full-row';
emojis.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'reaction-full-btn';
btn.textContent = emoji;
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (activeSlot !== null) {
current[activeSlot] = emoji;
this._saveQuickEmojis(current);
renderSlots();
}
});
row.appendChild(btn);
});
grid.appendChild(row);
}
// Custom emojis
if (this.customEmojis && this.customEmojis.length > 0) {
const label = document.createElement('div');
label.className = 'reaction-full-category';
label.textContent = t('emoji.categories.custom');
grid.appendChild(label);
const row = document.createElement('div');
row.className = 'reaction-full-row';
this.customEmojis.forEach(ce => {
const btn = document.createElement('button');
btn.className = 'reaction-full-btn';
btn.innerHTML = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji" style="width:22px;height:22px">`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (activeSlot !== null) {
current[activeSlot] = `:${ce.name}:`;
this._saveQuickEmojis(current);
renderSlots();
}
});
row.appendChild(btn);
});
grid.appendChild(row);
}
};
renderOptions();
editor.appendChild(grid);
// Done button
const doneBtn = document.createElement('button');
doneBtn.className = 'btn-sm btn-accent';
doneBtn.style.cssText = 'margin:8px;width:calc(100% - 16px)';
doneBtn.textContent = t('modals.common.done');
doneBtn.addEventListener('click', (e) => {
e.stopPropagation();
editor.remove();
});
editor.appendChild(doneBtn);
msgEl.appendChild(editor);
},
_showReactionPicker(msgEl, msgId) {
// Toggle: if this message already has a picker open, close it and bail
const existingPicker = msgEl.querySelector('.reaction-picker');
if (existingPicker) {
existingPicker.remove();
msgEl.classList.remove('showing-picker');
document.querySelectorAll('.reaction-full-picker').forEach(el => el.remove());
document.querySelectorAll('.quick-emoji-editor').forEach(el => el.remove());
if (this._reactionPickerClose) {
document.removeEventListener('click', this._reactionPickerClose);
this._reactionPickerClose = null;
}
return;
}
// Clean up previous close-on-click-outside handler so it can't
// interfere with the new picker (e.g. removing showing-picker class).
if (this._reactionPickerClose) {
document.removeEventListener('click', this._reactionPickerClose);
this._reactionPickerClose = null;
}
document.querySelectorAll('.showing-picker').forEach(el => el.classList.remove('showing-picker'));
document.querySelectorAll('.reaction-picker').forEach(el => el.remove());
document.querySelectorAll('.reaction-full-picker').forEach(el => el.remove());
document.querySelectorAll('.quick-emoji-editor').forEach(el => el.remove());
// Disable content-visibility containment so the picker isn't clipped
msgEl.classList.add('showing-picker');
const picker = document.createElement('div');
picker.className = 'reaction-picker';
const quickEmojis = this._getQuickEmojis();
quickEmojis.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'reaction-pick-btn';
// Check for custom emoji
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) btn.innerHTML = `<img src="${this._escapeHtml(ce.url)}" alt="${this._escapeHtml(emoji)}" class="custom-emoji" style="width:20px;height:20px">`;
else btn.textContent = emoji;
} else {
btn.textContent = emoji;
}
btn.addEventListener('click', () => {
this.socket.emit('add-reaction', { messageId: msgId, emoji });
picker.remove();
msgEl.classList.remove('showing-picker');
if (this._reactionPickerClose) {
document.removeEventListener('click', this._reactionPickerClose);
this._reactionPickerClose = null;
}
});
picker.appendChild(btn);
});
// "..." button opens the full emoji picker for reactions
const moreBtn = document.createElement('button');
moreBtn.className = 'reaction-pick-btn reaction-more-btn';
moreBtn.textContent = '⋯';
moreBtn.title = t('emoji.all_emojis_title');
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._showFullReactionPicker(msgEl, msgId, picker);
});
picker.appendChild(moreBtn);
// Separator + gear icon for customization
const sep = document.createElement('span');
sep.className = 'reaction-pick-sep';
sep.textContent = '|';
picker.appendChild(sep);
const gearBtn = document.createElement('button');
gearBtn.className = 'reaction-pick-btn reaction-gear-btn';
gearBtn.textContent = '⚙️';
gearBtn.title = t('emoji.customize_quick_title');
gearBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._showQuickEmojiEditor(picker, msgEl, msgId);
});
picker.appendChild(gearBtn);
msgEl.appendChild(picker);
// Flip picker below the message if it would be clipped above
requestAnimationFrame(() => {
const pickerRect = picker.getBoundingClientRect();
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (pickerRect.top < containerTop + 4) {
picker.classList.add('flip-below');
}
});
// Close on click outside
const close = (e) => {
if (!picker.contains(e.target) && !e.target.closest('.reaction-full-picker') && !e.target.closest('.quick-emoji-editor')) {
picker.remove();
msgEl.classList.remove('showing-picker');
document.querySelectorAll('.reaction-full-picker').forEach(el => el.remove());
document.removeEventListener('click', close);
this._reactionPickerClose = null;
}
};
this._reactionPickerClose = close;
setTimeout(() => document.addEventListener('click', close), 0);
},
_showFullReactionPicker(msgEl, msgId, quickPicker) {
// Remove any existing full picker
document.querySelectorAll('.reaction-full-picker').forEach(el => el.remove());
const panel = document.createElement('div');
panel.className = 'reaction-full-picker';
// Search bar
const searchRow = document.createElement('div');
searchRow.className = 'reaction-full-search';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = t('reactions.search_placeholder');
searchInput.className = 'reaction-full-search-input';
searchRow.appendChild(searchInput);
panel.appendChild(searchRow);
// Scrollable emoji grid
const grid = document.createElement('div');
grid.className = 'reaction-full-grid';
const renderAll = (filter) => {
grid.innerHTML = '';
const lowerFilter = filter ? filter.toLowerCase() : '';
for (const [category, emojis] of Object.entries(this.emojiCategories)) {
const matching = lowerFilter
? emojis.filter(e => {
const names = this.emojiNames[e] || '';
return e.includes(lowerFilter) || names.toLowerCase().includes(lowerFilter) || category.toLowerCase().includes(lowerFilter);
})
: emojis;
if (matching.length === 0) continue;
const label = document.createElement('div');
label.className = 'reaction-full-category';
label.textContent = t(`emoji.categories.${category.toLowerCase()}`) || category;
grid.appendChild(label);
const row = document.createElement('div');
row.className = 'reaction-full-row';
matching.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'reaction-full-btn';
btn.textContent = emoji;
btn.title = this.emojiNames[emoji] || '';
btn.addEventListener('click', () => {
this.socket.emit('add-reaction', { messageId: msgId, emoji });
panel.remove();
quickPicker.remove();
msgEl.classList.remove('showing-picker');
if (this._reactionPickerClose) {
document.removeEventListener('click', this._reactionPickerClose);
this._reactionPickerClose = null;
}
});
row.appendChild(btn);
});
grid.appendChild(row);
}
// Custom emojis section
if (this.customEmojis && this.customEmojis.length > 0) {
const customMatching = lowerFilter
? this.customEmojis.filter(e => e.name.toLowerCase().includes(lowerFilter) || 'custom'.includes(lowerFilter))
: this.customEmojis;
if (customMatching.length > 0) {
const label = document.createElement('div');
label.className = 'reaction-full-category';
label.textContent = t('emoji.categories.custom');
grid.appendChild(label);
const row = document.createElement('div');
row.className = 'reaction-full-row';
customMatching.forEach(ce => {
const btn = document.createElement('button');
btn.className = 'reaction-full-btn';
btn.innerHTML = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" title=":${this._escapeHtml(ce.name)}:" class="custom-emoji">`;
btn.addEventListener('click', () => {
this.socket.emit('add-reaction', { messageId: msgId, emoji: `:${ce.name}:` });
panel.remove();
quickPicker.remove();
msgEl.classList.remove('showing-picker');
if (this._reactionPickerClose) {
document.removeEventListener('click', this._reactionPickerClose);
this._reactionPickerClose = null;
}
});
row.appendChild(btn);
});
grid.appendChild(row);
}
}
};
renderAll('');
panel.appendChild(grid);
// Debounced search
let searchTimer;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => renderAll(searchInput.value.trim()), 150);
});
// Position the panel relative to quick picker so it never overlaps it
quickPicker.appendChild(panel);
if (quickPicker.classList.contains('flip-below')) {
panel.classList.add('flip-below');
}
requestAnimationFrame(() => {
const panelRect = panel.getBoundingClientRect();
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (panelRect.top < containerTop + 4) {
panel.classList.add('flip-below');
}
});
searchInput.focus();
},
// ═══════════════════════════════════════════════════════
// THREADS
// ═══════════════════════════════════════════════════════
_renderThreadPreview(parentId, thread) {
if (!thread || !thread.count) return '';
const participantAvatars = (thread.participants || []).map(p => {
if (p.avatar) {
return `<img class="thread-participant-avatar" src="${this._escapeHtml(p.avatar)}" alt="${this._escapeHtml(p.username)}" title="${this._escapeHtml(p.username)}">`;
}
const color = this._getUserColor(p.username);
const initial = p.username.charAt(0).toUpperCase();
return `<div class="thread-participant-avatar thread-participant-initial" style="background:${color}" title="${this._escapeHtml(p.username)}">${initial}</div>`;
}).join('');
const timeAgo = this._relativeTime(thread.lastReplyAt);
return `
<button class="thread-preview" data-thread-parent="${parentId}">
${participantAvatars}
<span class="thread-preview-count">${thread.count} ${thread.count === 1 ? 'Reply' : 'Replies'}</span>
<span class="thread-preview-time">${timeAgo}</span>
<span class="thread-preview-arrow"></span>
</button>
`;
},
_relativeTime(isoStr) {
if (!isoStr) return '';
const diff = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
},
_setThreadParentHeader(meta = {}) {
const wrap = document.getElementById('thread-parent-avatar-wrap');
const nameEl = document.getElementById('thread-parent-name');
if (!wrap || !nameEl) return;
const username = (meta.username || '').trim() || 'Thread starter';
const shape = (meta.avatarShape || 'circle') === 'square' ? 'square' : 'circle';
const shapeClass = shape === 'square' ? ' thread-parent-avatar-square' : '';
if (meta.avatar) {
wrap.innerHTML = `<img class="thread-parent-avatar${shapeClass}" src="${this._escapeHtml(meta.avatar)}" alt="${this._escapeHtml(username)}">`;
} else {
const initial = username.charAt(0).toUpperCase() || '?';
const color = this._getUserColor(username);
wrap.innerHTML = `<div class="thread-parent-avatar-initial${shapeClass}" style="background:${color}">${this._escapeHtml(initial)}</div>`;
}
nameEl.textContent = username;
nameEl.title = username;
},
_setThreadReply(msgEl, msgId) {
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const preview = rawContent.length > 70 ? rawContent.substring(0, 70) + '…' : rawContent;
this._threadReplyingTo = { id: msgId, username: author, content: rawContent };
const bar = document.getElementById('thread-reply-bar');
const text = document.getElementById('thread-reply-preview-text');
if (!bar || !text) return;
bar.style.display = 'flex';
text.innerHTML = `Replying to <strong>${this._escapeHtml(author)}</strong>: ${this._escapeHtml(preview)}`;
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_clearThreadReply() {
this._threadReplyingTo = null;
const bar = document.getElementById('thread-reply-bar');
if (bar) bar.style.display = 'none';
},
_quoteThreadMessage(msgEl) {
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('thread-input');
if (!input) return;
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
input.dispatchEvent(new Event('input'));
},
_openThread(parentId) {
this._activeThreadParent = parentId;
const panel = document.getElementById('thread-panel');
if (!panel) return;
panel.style.display = 'flex';
panel.dataset.parentId = parentId;
this._setThreadPiPEnabled(localStorage.getItem('haven_thread_panel_pip') === '1');
// Request thread messages from server
this.socket.emit('get-thread-messages', { parentId });
// Update header
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
const author = msgEl?.querySelector('.message-author')?.textContent || 'Thread starter';
document.getElementById('thread-panel-title').textContent = 'Thread';
const parentPreview = msgEl?.querySelector('.message-content')?.textContent || '';
document.getElementById('thread-parent-preview').textContent = parentPreview.length > 120 ? parentPreview.substring(0, 120) + '…' : parentPreview;
const avatarImg = msgEl?.querySelector('.message-avatar-img');
let avatar = null;
if (avatarImg && avatarImg.getAttribute('src')) avatar = avatarImg.getAttribute('src');
const avatarShape = (avatarImg && avatarImg.classList.contains('avatar-square')) ? 'square' : 'circle';
this._setThreadParentHeader({ username: author, avatar, avatarShape });
// Focus input
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_setThreadPiPEnabled(enabled) {
const panel = document.getElementById('thread-panel');
const pipBtn = document.getElementById('thread-panel-pip');
if (!panel || !pipBtn) return;
const isOn = !!enabled;
panel.classList.toggle('pip', isOn);
pipBtn.textContent = isOn ? '▣' : '⧉';
pipBtn.title = isOn ? 'Dock thread panel' : 'Pop out thread (PiP)';
pipBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
localStorage.setItem('haven_thread_panel_pip', isOn ? '1' : '0');
if (isOn) {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('haven_thread_panel_pip_rect') || 'null'); } catch {}
const minW = 320;
const maxW = Math.min(760, window.innerWidth - 28);
const minH = 240;
const footerOffset = (() => {
const raw = getComputedStyle(document.body).getPropertyValue('--thread-footer-offset');
const v = parseInt(raw, 10);
return Number.isFinite(v) ? v : 0;
})();
const maxH = Math.max(minH, window.innerHeight - footerOffset - 28);
const width = Math.max(minW, Math.min(maxW, (saved && saved.width) || panel.offsetWidth || 420));
const height = Math.max(minH, Math.min(maxH, (saved && saved.height) || panel.offsetHeight || 460));
const defaultLeft = Math.max(0, window.innerWidth - width - 14);
const defaultTop = Math.max(0, window.innerHeight - footerOffset - height - 14);
const left = Math.max(0, Math.min(window.innerWidth - width, (saved && Number.isFinite(saved.left)) ? saved.left : defaultLeft));
const top = Math.max(0, Math.min(window.innerHeight - footerOffset - height, (saved && Number.isFinite(saved.top)) ? saved.top : defaultTop));
panel.style.width = `${Math.round(width)}px`;
panel.style.height = `${Math.round(height)}px`;
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
} else {
panel.style.height = '';
panel.style.left = '';
panel.style.top = '';
panel.style.right = '';
panel.style.bottom = '';
}
},
_toggleThreadPiP() {
const panel = document.getElementById('thread-panel');
if (!panel) return;
this._setThreadPiPEnabled(!panel.classList.contains('pip'));
},
_closeThread() {
this._activeThreadParent = null;
this._clearThreadReply();
const panel = document.getElementById('thread-panel');
if (panel) {
panel.style.display = 'none';
panel.dataset.parentId = '';
}
},
_sendThreadMessage() {
const input = document.getElementById('thread-input');
if (!input) return;
const content = input.value.trim();
if (!content) return;
const parentId = this._activeThreadParent;
if (!parentId) return;
const replyTo = this._threadReplyingTo ? this._threadReplyingTo.id : null;
this.socket.emit('send-thread-message', { parentId, content, replyTo }, (resp) => {
if (resp && resp.error) {
this._showToast(resp.error, 'error');
return;
}
this._clearThreadReply();
});
input.value = '';
},
_appendThreadMessage(msg) {
const container = document.getElementById('thread-messages');
if (!container) return;
const color = this._getUserColor(msg.username);
const initial = msg.username.charAt(0).toUpperCase();
let avatarHtml;
if (msg.avatar) {
avatarHtml = `<img class="thread-msg-avatar" src="${this._escapeHtml(msg.avatar)}" alt="${initial}">`;
} else {
avatarHtml = `<div class="thread-msg-avatar thread-msg-avatar-initial" style="background:${color}">${initial}</div>`;
}
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
const canEdit = msg.user_id === this.user.id;
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
const iReact = iconPair('😀', '<svg class="thread-action-react-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
const threadCoreToolbarBtns = `<button data-thread-action="react" title="React" aria-label="React">${iReact}</button><button data-thread-action="reply" title="Reply">${iReply}</button><button data-thread-action="quote" title="Quote">${iQuote}</button>`;
let threadOverflowToolbarBtns = '';
if (canEdit) threadOverflowToolbarBtns += `<button data-thread-action="edit" title="Edit">${iEdit}</button>`;
if (canDelete) threadOverflowToolbarBtns += `<button data-thread-action="delete" title="Delete">${iDelete}</button>`;
const threadOverflowHtml = threadOverflowToolbarBtns
? `<div class="thread-msg-more"><button class="thread-msg-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="thread-msg-overflow">${threadOverflowToolbarBtns}</div></div>`
: '';
const el = document.createElement('div');
el.className = 'thread-message';
el.dataset.msgId = msg.id;
el.dataset.rawContent = msg.content;
el.innerHTML = `
<div class="thread-msg-row">
${avatarHtml}
<div class="thread-msg-body">
<div class="thread-msg-header">
<span class="thread-msg-author" style="color:${color}">${this._escapeHtml(msg.username)}</span>
<span class="thread-msg-time">${this._formatTime(msg.created_at)}</span>
<span class="thread-msg-header-spacer"></span>
<div class="thread-msg-toolbar">
<div class="msg-toolbar-group">${threadCoreToolbarBtns}</div>
${threadOverflowHtml}
</div>
</div>
${replyHtml}
<div class="thread-msg-content">${this._formatContent(msg.content)}</div>
${reactionsHtml}
</div>
</div>
`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
},
_updateThreadPreview(parentId, thread) {
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
if (!msgEl) return;
const oldPreview = msgEl.querySelector('.thread-preview');
const newHtml = this._renderThreadPreview(parentId, thread);
if (oldPreview) {
oldPreview.outerHTML = newHtml;
} else if (newHtml) {
// Insert after reactions row, or after message-content
const reactions = msgEl.querySelector('.reactions-row');
const content = msgEl.querySelector('.message-content');
const insertAfter = reactions || content;
if (insertAfter) insertAfter.insertAdjacentHTML('afterend', newHtml);
}
},
// ═══════════════════════════════════════════════════════
// REPLY
// ═══════════════════════════════════════════════════════
_renderReplyBanner(replyCtx) {
const previewText = replyCtx.content.length > 80
? replyCtx.content.substring(0, 80) + '…'
: replyCtx.content;
const color = this._getUserColor(replyCtx.username);
return `
<div class="reply-banner" data-reply-msg-id="${replyCtx.id}">
<span class="reply-line" style="background:${color}"></span>
<span class="reply-author" style="color:${color}">${this._escapeHtml(this._getNickname(replyCtx.user_id, replyCtx.username))}</span>
<span class="reply-preview">${this._escapeHtml(previewText)}</span>
</div>
`;
},
_setReply(msgEl, msgId) {
// Get message info — works for both full messages and compact messages
let author = msgEl.querySelector('.message-author')?.textContent;
if (!author) {
// Compact message — look up the previous full message's author
let prev = msgEl.previousElementSibling;
while (prev) {
const authorEl = prev.querySelector('.message-author');
if (authorEl) { author = authorEl.textContent; break; }
prev = prev.previousElementSibling;
}
}
author = author || 'someone';
const content = msgEl.querySelector('.message-content')?.textContent || '';
const preview = content.length > 60 ? content.substring(0, 60) + '…' : content;
this.replyingTo = { id: msgId, username: author, content };
const bar = document.getElementById('reply-bar');
bar.style.display = 'flex';
document.getElementById('reply-preview-text').innerHTML =
`Replying to <strong>${this._escapeHtml(author)}</strong>: ${this._escapeHtml(preview)}`;
document.getElementById('message-input').focus();
},
_clearReply() {
this.replyingTo = null;
const bar = document.getElementById('reply-bar');
if (bar) bar.style.display = 'none';
},
_quoteMessage(msgEl) {
// Get the raw text content of the message
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.textContent || '';
// Get the author name
let author = msgEl.querySelector('.message-author')?.textContent;
if (!author) {
let prev = msgEl.previousElementSibling;
while (prev) {
const authorEl = prev.querySelector('.message-author');
if (authorEl) { author = authorEl.textContent; break; }
prev = prev.previousElementSibling;
}
}
author = author || 'someone';
// Build the blockquote text — each line prefixed with >
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('message-input');
// If there's already text, add a newline before the quote
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
// Trigger input event so textarea auto-resizes
input.dispatchEvent(new Event('input'));
},
// ═══════════════════════════════════════════════════════
// EDIT MESSAGE
// ═══════════════════════════════════════════════════════
_startEditMessage(msgEl, msgId) {
// Guard against re-entering edit mode
if (msgEl.classList.contains('editing')) return;
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
if (!contentEl) return;
// Use the stored raw markdown content (set on render and kept in sync on
// edit events). Falls back to textContent only for very old DOM nodes that
// pre-date this attribute, but avoids the two bugs that textContent causes:
// 1) markdown formatting stripped (bold/italic/etc. lost)
// 2) '(edited)' tag text leaked into the textarea on repeated edits.
const rawText = msgEl.dataset.rawContent ?? contentEl.textContent;
// Replace content with an editable textarea
const originalHtml = contentEl.innerHTML;
contentEl.innerHTML = '';
msgEl.classList.add('editing'); // hide toolbar while editing
const textarea = document.createElement('textarea');
textarea.className = 'edit-textarea';
textarea.value = rawText;
textarea.rows = 1;
textarea.maxLength = 2000;
contentEl.appendChild(textarea);
// Track active edit textarea for emoji picker redirection
this._activeEditTextarea = textarea;
const btnRow = document.createElement('div');
btnRow.className = 'edit-actions';
btnRow.innerHTML = `<button class="edit-emoji-btn" title="${t('app.input_bar.emoji_btn') || 'Emoji'}">😀</button><button class="edit-save-btn">${t('modals.common.save')}</button><button class="edit-cancel-btn">${t('modals.common.cancel')}</button>`;
contentEl.appendChild(btnRow);
// Emoji button in edit bar opens the picker
btnRow.querySelector('.edit-emoji-btn').addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this._activeEditTextarea = textarea;
this._toggleEmojiPicker();
});
textarea.focus();
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
const cancel = () => {
msgEl.classList.remove('editing');
contentEl.innerHTML = originalHtml;
if (this._activeEditTextarea === textarea) this._activeEditTextarea = null;
// Close emoji picker if it was open for this edit
const picker = document.getElementById('emoji-picker');
if (picker) picker.style.display = 'none';
// Close autocomplete dropdowns
this._hideMentionDropdown();
this._hideEmojiDropdown();
};
btnRow.querySelector('.edit-cancel-btn').addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
cancel();
});
btnRow.querySelector('.edit-save-btn').addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
let newContent = textarea.value.trim();
if (!newContent) return cancel();
if (newContent === rawText) return cancel();
// E2E: encrypt edited DM content
const partner = this._getE2EPartner();
if (partner) {
try {
newContent = await this.e2e.encrypt(newContent, partner.userId, partner.publicKeyJwk);
} catch (err) {
console.warn('[E2E] Failed to encrypt edited message:', err);
}
}
this.socket.emit('edit-message', { messageId: msgId, content: newContent });
cancel(); // will be updated by the server event
});
textarea.addEventListener('keydown', (e) => {
e.stopPropagation();
// Handle @mention and :emoji dropdown navigation in edit mode
const mentionDd = document.getElementById('mention-dropdown');
if (mentionDd && mentionDd.style.display !== 'none') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this._navigateMentionDropdown(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
const active = mentionDd.querySelector('.mention-item.active');
if (active) { e.preventDefault(); active.click(); return; }
}
if (e.key === 'Escape') { this._hideMentionDropdown(); return; }
}
const emojiDd = document.getElementById('emoji-dropdown');
if (emojiDd && emojiDd.style.display !== 'none') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this._navigateEmojiDropdown(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
const active = emojiDd.querySelector('.emoji-ac-item.active');
if (active) { e.preventDefault(); active.click(); return; }
}
if (e.key === 'Escape') { this._hideEmojiDropdown(); return; }
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
btnRow.querySelector('.edit-save-btn').click();
}
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
});
// Enable @mention and :emoji autocomplete in edit textarea
textarea.addEventListener('input', () => {
this._checkMentionTrigger(textarea);
this._checkEmojiTrigger(textarea);
});
// Click inside edit area should not bubble to delegation handler
contentEl.addEventListener('click', (e) => {
e.stopPropagation();
}, { once: false });
},
// ═══════════════════════════════════════════════════════
// ADMIN MODERATION UI
// ═══════════════════════════════════════════════════════
_showAdminActionModal(action, userId, username) {
this.adminActionTarget = { action, userId, username };
const modal = document.getElementById('admin-action-modal');
const title = document.getElementById('admin-action-title');
const desc = document.getElementById('admin-action-desc');
const durationGroup = document.getElementById('admin-duration-group');
const scrubGroup = document.getElementById('admin-scrub-group');
const scrubCheckbox = document.getElementById('admin-scrub-checkbox');
const scrubScopeRow = document.getElementById('admin-scrub-scope-row');
const confirmBtn = document.getElementById('confirm-admin-action-btn');
const labels = {
kick: t('modals.admin_action.label_kick'),
ban: t('modals.admin_action.label_ban'),
mute: t('modals.admin_action.label_mute'),
'delete-user': t('modals.admin_action.label_delete_user')
};
title.textContent = `${labels[action] || action}${username}`;
desc.textContent = action === 'ban'
? t('modals.admin_action.desc_ban')
: action === 'mute'
? t('modals.admin_action.desc_mute')
: action === 'delete-user'
? t('modals.admin_action.desc_delete_user')
: t('modals.admin_action.desc_kick');
durationGroup.style.display = action === 'mute' ? 'block' : 'none';
// Show scrub option for kick, ban, and delete-user
const hasScrub = ['kick', 'ban', 'delete-user'].includes(action);
scrubGroup.style.display = hasScrub ? 'block' : 'none';
scrubCheckbox.checked = false;
// Kick gets scope dropdown (channel vs server), ban/delete are server-wide only
scrubScopeRow.style.display = 'none';
if (action === 'kick') {
scrubCheckbox.onchange = () => { scrubScopeRow.style.display = scrubCheckbox.checked ? 'block' : 'none'; };
} else {
scrubCheckbox.onchange = null;
}
confirmBtn.textContent = labels[action] || t('modals.common.confirm');
document.getElementById('admin-action-reason').value = '';
document.getElementById('admin-action-duration').value = '10';
document.getElementById('admin-scrub-scope').value = 'channel';
modal.style.display = 'flex';
modal.style.zIndex = '100002';
},
_confirmTransferAdmin(userId, username) {
// Build a custom modal for transfer admin with password verification
this._closeUserGearMenu();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay transfer-admin-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal transfer-admin-modal">
<div class="modal-header">
<h4>🔑 ${t('modals.transfer_admin.title')}</h4>
<button class="modal-close-btn transfer-admin-close">&times;</button>
</div>
<div class="modal-body">
<div class="transfer-admin-warning">
<div class="transfer-admin-warning-icon">⚠️</div>
<div class="transfer-admin-warning-text">
${t('modals.transfer_admin.warning', { username: this._escapeHtml(username) })}
</div>
</div>
<p class="transfer-admin-note">${t('modals.transfer_admin.note')}</p>
<div class="form-group">
<label class="form-label">${t('modals.transfer_admin.password_label')}</label>
<input type="password" id="transfer-admin-pw" class="form-input" placeholder="${t('modals.transfer_admin.password_placeholder')}" autocomplete="current-password">
</div>
<p id="transfer-admin-error" class="transfer-admin-error"></p>
</div>
<div class="modal-footer">
<button class="btn-secondary transfer-admin-cancel">${t('modals.common.cancel')}</button>
<button class="btn-danger-fill transfer-admin-confirm">${t('modals.transfer_admin.confirm_btn')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const pwInput = overlay.querySelector('#transfer-admin-pw');
const errorEl = overlay.querySelector('#transfer-admin-error');
const confirmBtn = overlay.querySelector('.transfer-admin-confirm');
const close = () => overlay.remove();
overlay.querySelector('.transfer-admin-close').addEventListener('click', close);
overlay.querySelector('.transfer-admin-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
pwInput.focus();
pwInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmBtn.click(); });
confirmBtn.addEventListener('click', () => {
const password = pwInput.value.trim();
if (!password) {
errorEl.textContent = t('modals.transfer_admin.error_required');
errorEl.style.display = '';
pwInput.focus();
return;
}
confirmBtn.disabled = true;
confirmBtn.textContent = t('modals.transfer_admin.transferring');
this.socket.emit('transfer-admin', { userId, password }, (res) => {
if (res && res.error) {
errorEl.textContent = res.error;
errorEl.style.display = '';
confirmBtn.disabled = false;
confirmBtn.textContent = t('modals.transfer_admin.confirm_btn');
pwInput.value = '';
pwInput.focus();
} else if (res && res.success) {
close();
this._showToast(res.message || 'Admin transferred', 'info');
}
});
});
},
// ── Generic prompt modal (replaces window.prompt for Electron compat) ──
_showPromptModal(title, message, defaultValue = '') {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.style.zIndex = '100002';
overlay.innerHTML = `
<div class="modal" style="max-width:380px">
<h3 style="margin-top:0">${this._escapeHtml(title)}</h3>
${message ? `<p class="muted-text" style="margin:0 0 12px;white-space:pre-line">${this._escapeHtml(message)}</p>` : ''}
<input type="text" class="modal-input" id="prompt-modal-input" value="${this._escapeHtml(defaultValue)}" style="width:100%;box-sizing:border-box">
<div class="modal-actions" style="margin-top:12px">
<button class="btn-sm" id="prompt-modal-cancel">${t('modals.common.cancel')}</button>
<button class="btn-sm btn-accent" id="prompt-modal-ok">${t('modals.common.ok')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#prompt-modal-input');
input.focus();
input.select();
const close = (val) => { overlay.remove(); resolve(val); };
overlay.querySelector('#prompt-modal-cancel').addEventListener('click', () => close(null));
overlay.querySelector('#prompt-modal-ok').addEventListener('click', () => close(input.value));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') close(input.value);
if (e.key === 'Escape') close(null);
});
});
},
};