diff --git a/public/css/style.css b/public/css/style.css
index 8f8c6bc..9fbde05 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -4109,6 +4109,137 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
.server-icon.remote:hover .server-remove { display: flex; }
+/* Manage Servers gear button */
+.server-icon.manage-servers {
+ background: transparent;
+ border: 2px dashed var(--border-light);
+ margin-top: 2px;
+}
+.server-icon.manage-servers:hover {
+ border-color: var(--accent);
+ background: transparent;
+ border-radius: 12px;
+}
+.server-icon.manage-servers .server-icon-text {
+ font-size: 18px;
+ color: var(--text-secondary);
+ transition: color 0.15s;
+}
+.server-icon.manage-servers:hover .server-icon-text {
+ color: var(--accent);
+}
+
+/* Manage Servers modal list */
+.manage-servers-list {
+ max-height: 360px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 12px 0;
+}
+.manage-servers-list:empty::before {
+ content: 'No servers added yet. Click "+ Add Server" below.';
+ color: var(--text-muted);
+ text-align: center;
+ padding: 24px 0;
+ font-size: 13px;
+}
+.manage-server-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ transition: background 0.15s;
+}
+.manage-server-row:hover {
+ background: var(--bg-hover);
+}
+.manage-server-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--bg-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ overflow: hidden;
+}
+.manage-server-icon img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: inherit;
+}
+.manage-server-info {
+ flex: 1;
+ min-width: 0;
+}
+.manage-server-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.manage-server-url {
+ font-size: 11px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-mono);
+}
+.manage-server-status {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 9px;
+ flex-shrink: 0;
+}
+.manage-server-status.online {
+ background: rgba(67, 181, 129, 0.15);
+ color: var(--success);
+}
+.manage-server-status.offline {
+ background: rgba(240, 71, 71, 0.12);
+ color: var(--text-muted);
+}
+.manage-server-status.unknown {
+ background: rgba(250, 166, 26, 0.12);
+ color: var(--warning);
+}
+.manage-server-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.manage-server-actions button {
+ background: none;
+ border: none;
+ font-size: 14px;
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ transition: background 0.15s, color 0.15s;
+}
+.manage-server-actions button:hover {
+ background: var(--bg-active);
+ color: var(--text-primary);
+}
+.manage-server-actions button.danger-action:hover {
+ background: #3a1515;
+ color: var(--danger);
+}
+
/* ═══════════════════════════════════════════════════════════
MODAL
@@ -4813,6 +4944,31 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
transform: scale(1.2);
}
+/* Custom emoji (inline in messages + pickers) */
+.custom-emoji {
+ width: 1.4em;
+ height: 1.4em;
+ vertical-align: middle;
+ object-fit: contain;
+ display: inline;
+ margin: 0 1px;
+}
+.reaction-custom-emoji {
+ width: 1em;
+ height: 1em;
+}
+.emoji-item .custom-emoji {
+ width: 24px;
+ height: 24px;
+}
+.reaction-full-btn .custom-emoji {
+ width: 22px;
+ height: 22px;
+}
+.custom-emoji-preview {
+ border-radius: 4px;
+}
+
/* ═══════════════════════════════════════════════════════════
GIF PICKER
@@ -5080,6 +5236,42 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
letter-spacing: 1px;
}
+.reaction-pick-sep {
+ color: var(--text-muted);
+ font-size: 16px;
+ opacity: 0.4;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ padding: 0 1px;
+}
+
+.reaction-gear-btn {
+ font-size: 14px;
+ color: var(--text-muted);
+ opacity: 0.6;
+ transition: opacity 0.15s;
+}
+.reaction-gear-btn:hover {
+ opacity: 1;
+}
+
+.quick-emoji-slots {
+ display: flex;
+ gap: 4px;
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 4px;
+}
+.quick-emoji-slot {
+ border: 2px solid transparent;
+ border-radius: var(--radius-sm);
+}
+.quick-emoji-slot.active {
+ border-color: var(--accent);
+ background: var(--accent-glow);
+}
+
/* ── Full Reaction Emoji Picker (opened via "...") ── */
.reaction-full-picker {
position: absolute;
@@ -5947,13 +6139,6 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
SETTINGS POPOUT MODAL
═══════════════════════════════════════════════════════════ */
-.modal-settings {
- max-width: 460px;
- max-height: 85vh;
- overflow-y: auto;
- padding: 0;
-}
-
.settings-header {
display: flex;
align-items: center;
@@ -5993,6 +6178,72 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
color: var(--text-primary);
}
+/* ── Settings Layout (Nav + Body) ─────────────────────── */
+.settings-layout {
+ display: flex;
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
+}
+
+.settings-nav {
+ width: 150px;
+ min-width: 150px;
+ padding: 8px 0 8px 8px;
+ overflow-y: auto;
+ border-right: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.settings-nav-group {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ padding: 10px 10px 4px;
+ user-select: none;
+}
+
+.settings-nav-item {
+ padding: 5px 10px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ margin-bottom: 1px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: background 0.15s, color 0.15s;
+}
+
+.settings-nav-item:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.settings-nav-item.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+.settings-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+ min-width: 0;
+}
+
+.modal-settings {
+ max-width: 640px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0;
+}
+
/* ── Activities / Games Launcher ──────────────────────── */
.modal-activities {
max-width: 520px;
@@ -6735,8 +6986,10 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
.modal { padding: 20px; width: 95%; max-height: 85vh; overflow-y: auto; }
.modal h3 { font-size: 18px; }
- /* Settings modal — scrollable on phone */
- .modal-settings { max-height: 80vh; overflow-y: auto; }
+ /* Settings modal — scrollable on phone, hide nav */
+ .modal-settings { max-height: 80vh; }
+ .settings-nav { display: none; }
+ .settings-body { overflow-y: auto; }
/* Toasts — full width */
#toast-container { left: 50%; right: auto; top: calc(8px + env(safe-area-inset-top, 0px)); transform: translateX(-50%); }
@@ -9152,6 +9405,12 @@ input[type="range"]:not(.rgb-slider)::-moz-range-track {
height: 28px;
font-size: 11px;
}
+[data-density="compact"] .message-compact {
+ padding-left: 44px;
+}
+[data-density="compact"] .message-compact .compact-time {
+ font-size: 9px;
+}
[data-density="compact"] .msg-body {
font-size: 13px;
}
diff --git a/public/js/app.js b/public/js/app.js
index abd441f..de3a5db 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -103,6 +103,8 @@ class HavenApp {
this._isServerMod = () => this.user.isAdmin || (this.user.effectiveLevel || 0) >= 50;
this._hasPerm = (p) => this.user.isAdmin || (this.user.permissions || []).includes('*') || (this.user.permissions || []).includes(p);
+ this.customEmojis = []; // [{name, url}] — loaded from server
+
this._init();
}
@@ -132,6 +134,7 @@ class HavenApp {
this._setupIdleDetection();
// this._setupAvatarUpload(); // Moved to top of _init
this._setupSoundManagement();
+ this._setupEmojiManagement();
this._setupWebhookManagement();
this._initRoleManagement();
this._initServerBranding();
@@ -307,7 +310,7 @@ class HavenApp {
});
this.socket.on('connect_error', (err) => {
- if (err.message === 'Invalid token' || err.message === 'Authentication required') {
+ if (err.message === 'Invalid token' || err.message === 'Authentication required' || err.message === 'Session expired') {
localStorage.removeItem('haven_token');
localStorage.removeItem('haven_user');
window.location.href = '/';
@@ -969,6 +972,7 @@ class HavenApp {
// Delete channel
// ── Channel context menu ("..." on hover) ──────────
this._initChannelContextMenu();
+ this._initDmContextMenu();
// Delete channel with TWO confirmations (from ctx menu)
document.querySelector('[data-action="delete"]')?.addEventListener('click', () => {
const code = this._ctxMenuChannel;
@@ -989,6 +993,20 @@ class HavenApp {
else { muted.push(code); this._showToast('Channel muted', 'success'); }
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
});
+ // Join voice from context menu
+ document.querySelector('[data-action="join-voice"]')?.addEventListener('click', () => {
+ const code = this._ctxMenuChannel;
+ if (!code) return;
+ this._closeChannelCtxMenu();
+ // Switch to the channel first, then join voice
+ this.switchChannel(code);
+ setTimeout(() => this._joinVoice(), 300);
+ });
+ // Disconnect from voice via context menu
+ document.querySelector('[data-action="leave-voice"]')?.addEventListener('click', () => {
+ this._closeChannelCtxMenu();
+ this._leaveVoice();
+ });
// Toggle streams permission
document.querySelector('[data-action="toggle-streams"]')?.addEventListener('click', () => {
const code = this._ctxMenuChannel;
@@ -1563,6 +1581,20 @@ class HavenApp {
}
});
+ // Reply banner click — scroll to the original message
+ document.getElementById('messages').addEventListener('click', (e) => {
+ const banner = e.target.closest('.reply-banner');
+ if (!banner) return;
+ const replyMsgId = banner.dataset.replyMsgId;
+ if (!replyMsgId) return;
+ const targetMsg = document.querySelector(`[data-msg-id="${replyMsgId}"]`);
+ if (targetMsg) {
+ targetMsg.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ targetMsg.classList.add('highlight-flash');
+ setTimeout(() => targetMsg.classList.remove('highlight-flash'), 2000);
+ }
+ });
+
// Emoji picker toggle
document.getElementById('emoji-btn').addEventListener('click', () => {
this._toggleEmojiPicker();
@@ -1724,10 +1756,12 @@ class HavenApp {
document.getElementById('open-settings-btn').addEventListener('click', () => {
this._snapshotAdminSettings();
document.getElementById('settings-modal').style.display = 'flex';
+ this._syncSettingsNav();
});
document.getElementById('mobile-settings-btn')?.addEventListener('click', () => {
this._snapshotAdminSettings();
document.getElementById('settings-modal').style.display = 'flex';
+ this._syncSettingsNav();
document.getElementById('app-body')?.classList.remove('mobile-sidebar-open');
document.getElementById('mobile-overlay')?.classList.remove('active');
});
@@ -1741,6 +1775,20 @@ class HavenApp {
this._saveAdminSettings();
});
+ // ── Settings nav click-to-scroll ─────────────────────
+ document.querySelectorAll('.settings-nav-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const targetId = item.dataset.target;
+ const target = document.getElementById(targetId);
+ if (!target) return;
+ // Scroll into view within the settings body
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Update active state
+ document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
+ item.classList.add('active');
+ });
+ });
+
// ── Password change ──────────────────────────────────
document.getElementById('change-password-btn').addEventListener('click', async () => {
const cur = document.getElementById('current-password').value;
@@ -1928,6 +1976,21 @@ class HavenApp {
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
});
+ // ── Manage Servers gear button & modal ──────────────
+ document.getElementById('manage-servers-btn')?.addEventListener('click', () => {
+ this._openManageServersModal();
+ });
+ document.getElementById('manage-servers-close-btn')?.addEventListener('click', () => {
+ document.getElementById('manage-servers-modal').style.display = 'none';
+ });
+ document.getElementById('manage-servers-modal')?.addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
+ });
+ document.getElementById('manage-servers-add-btn')?.addEventListener('click', () => {
+ document.getElementById('manage-servers-modal').style.display = 'none';
+ document.getElementById('add-server-btn').click();
+ });
+
// ── Channel Code Settings Modal ─────────────────────
document.getElementById('channel-code-settings-btn')?.addEventListener('click', () => {
if (!this.currentChannel || !this.user.isAdmin) return;
@@ -2052,6 +2115,63 @@ class HavenApp {
document.getElementById('add-server-name-input').focus();
}
+ _openManageServersModal() {
+ this._renderManageServersList();
+ document.getElementById('manage-servers-modal').style.display = 'flex';
+ }
+
+ _renderManageServersList() {
+ const container = document.getElementById('manage-servers-list');
+ const servers = this.serverManager.getAll();
+ container.innerHTML = '';
+ if (servers.length === 0) return; // CSS :empty handles empty state
+
+ servers.forEach(s => {
+ const row = document.createElement('div');
+ row.className = 'manage-server-row';
+
+ const online = s.status.online;
+ const statusClass = online === true ? 'online' : online === false ? 'offline' : 'unknown';
+ const statusText = online === true ? 'Online' : online === false ? 'Offline' : 'Checking...';
+ const initial = s.name.charAt(0).toUpperCase();
+ const iconUrl = s.icon || (s.status.icon || null);
+ const iconContent = iconUrl
+ ? `
})
`
+ : initial;
+
+ row.innerHTML = `
+
${iconContent}
+
+
${this._escapeHtml(s.name)}
+
${this._escapeHtml(s.url)}
+
+
${statusText}
+
+
+
+
+
+ `;
+
+ row.querySelector('.manage-server-visit').addEventListener('click', () => {
+ window.open(s.url, '_blank', 'noopener');
+ });
+ row.querySelector('.manage-server-edit').addEventListener('click', () => {
+ document.getElementById('manage-servers-modal').style.display = 'none';
+ this._editServer(s.url);
+ });
+ row.querySelector('.manage-server-delete').addEventListener('click', () => {
+ if (!confirm(`Remove "${s.name}" from your server list?`)) return;
+ this.serverManager.remove(s.url);
+ this._renderServerBar();
+ this._renderManageServersList();
+ this._showToast(`Removed "${s.name}"`, 'success');
+ });
+
+ container.appendChild(row);
+ });
+ }
+
_renderServerBar() {
const list = document.getElementById('server-list');
const servers = this.serverManager.getAll();
@@ -2803,6 +2923,21 @@ class HavenApp {
// ═══════════════════════════════════════════════════════
_setupSoundManagement() {
+ // Open sound management modal
+ const openBtn = document.getElementById('open-sound-manager-btn');
+ if (openBtn) {
+ openBtn.addEventListener('click', () => {
+ document.getElementById('sound-modal').style.display = 'flex';
+ });
+ }
+ // Close sound modal
+ document.getElementById('close-sound-modal-btn')?.addEventListener('click', () => {
+ document.getElementById('sound-modal').style.display = 'none';
+ });
+ document.getElementById('sound-modal')?.addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
+ });
+
const uploadBtn = document.getElementById('sound-upload-btn');
const fileInput = document.getElementById('sound-file-input');
const nameInput = document.getElementById('sound-name-input');
@@ -2944,6 +3079,116 @@ class HavenApp {
});
}
+ // ═══════════════════════════════════════════════════════
+ // CUSTOM EMOJI MANAGEMENT
+ // ═══════════════════════════════════════════════════════
+
+ _setupEmojiManagement() {
+ // Open emoji management modal
+ const openEmojiBtn = document.getElementById('open-emoji-manager-btn');
+ if (openEmojiBtn) {
+ openEmojiBtn.addEventListener('click', () => {
+ document.getElementById('emoji-modal').style.display = 'flex';
+ });
+ }
+ // Close emoji modal
+ document.getElementById('close-emoji-modal-btn')?.addEventListener('click', () => {
+ document.getElementById('emoji-modal').style.display = 'none';
+ });
+ document.getElementById('emoji-modal')?.addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
+ });
+
+ const uploadBtn = document.getElementById('emoji-upload-btn');
+ const fileInput = document.getElementById('emoji-file-input');
+ const nameInput = document.getElementById('emoji-name-input');
+ if (!uploadBtn || !fileInput) return;
+
+ uploadBtn.addEventListener('click', async () => {
+ const file = fileInput.files[0];
+ const name = nameInput ? nameInput.value.trim().replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase() : '';
+ if (!file) return this._showToast('Select an image file', 'error');
+ if (!name) return this._showToast('Enter an emoji name (lowercase, no spaces)', 'error');
+ if (file.size > 256 * 1024) return this._showToast('Emoji file too large (max 256 KB)', 'error');
+
+ const formData = new FormData();
+ formData.append('emoji', file);
+ formData.append('name', name);
+
+ try {
+ this._showToast('Uploading emoji...', 'info');
+ const res = await fetch('/api/upload-emoji', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${this.token}` },
+ body: formData
+ });
+ if (!res.ok) {
+ let errMsg = `Upload failed (${res.status})`;
+ try { const d = await res.json(); errMsg = d.error || errMsg; } catch {}
+ return this._showToast(errMsg, 'error');
+ }
+ this._showToast(`Emoji :${name}: uploaded!`, 'success');
+ fileInput.value = '';
+ nameInput.value = '';
+ this._loadCustomEmojis();
+ } catch {
+ this._showToast('Upload failed', 'error');
+ }
+ });
+
+ this._loadCustomEmojis();
+ }
+
+ async _loadCustomEmojis() {
+ try {
+ const res = await fetch('/api/emojis', {
+ headers: { 'Authorization': `Bearer ${this.token}` }
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ this.customEmojis = data.emojis || []; // [{name, url}]
+ this._renderEmojiList(this.customEmojis);
+ } catch { /* ignore */ }
+ }
+
+ _renderEmojiList(emojis) {
+ const list = document.getElementById('custom-emojis-list');
+ if (!list) return;
+
+ if (emojis.length === 0) {
+ list.innerHTML = '
No custom emojis uploaded
';
+ return;
+ }
+
+ list.innerHTML = emojis.map(e => `
+
+
})
+
:${this._escapeHtml(e.name)}:
+
+
+ `).join('');
+
+ list.querySelectorAll('.emoji-delete-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const name = btn.dataset.name;
+ try {
+ const res = await fetch(`/api/emojis/${encodeURIComponent(name)}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${this.token}` }
+ });
+ if (res.ok) {
+ this._showToast(`Emoji :${name}: deleted`, 'success');
+ this._loadCustomEmojis();
+ } else {
+ this._showToast('Delete failed', 'error');
+ }
+ } catch {
+ this._showToast('Delete failed', 'error');
+ }
+ });
+ });
+ }
+
// ═══════════════════════════════════════════════════════
// WEBHOOKS / BOT MANAGEMENT
// ═══════════════════════════════════════════════════════
@@ -4042,6 +4287,13 @@ class HavenApp {
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const muteBtn = menu.querySelector('[data-action="mute"]');
if (muteBtn) muteBtn.textContent = muted.includes(code) ? '🔕 Unmute Channel' : '🔔 Mute Channel';
+ // Show/hide voice options based on current voice state
+ const joinVoiceBtn = menu.querySelector('[data-action="join-voice"]');
+ const leaveVoiceBtn = menu.querySelector('[data-action="leave-voice"]');
+ const inVoice = this.voice && this.voice.inVoice;
+ const inThisChannel = inVoice && this.voice.currentChannel === code;
+ if (joinVoiceBtn) joinVoiceBtn.style.display = inThisChannel ? 'none' : '';
+ if (leaveVoiceBtn) leaveVoiceBtn.style.display = inVoice ? '' : 'none';
// Position near the button
const rect = btnEl.getBoundingClientRect();
menu.style.display = 'block';
@@ -4060,6 +4312,74 @@ class HavenApp {
this._ctxMenuChannel = null;
}
+ /* ── DM context menu helpers ──────────────────────────── */
+ _initDmContextMenu() {
+ this._dmCtxMenuEl = document.getElementById('dm-ctx-menu');
+ this._dmCtxMenuCode = null;
+
+ // Mute DM
+ document.querySelector('[data-action="dm-mute"]')?.addEventListener('click', () => {
+ const code = this._dmCtxMenuCode;
+ if (!code) return;
+ this._closeDmCtxMenu();
+ const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
+ const idx = muted.indexOf(code);
+ if (idx >= 0) { muted.splice(idx, 1); this._showToast('DM unmuted', 'success'); }
+ else { muted.push(code); this._showToast('DM muted', 'success'); }
+ localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
+ });
+
+ // Delete DM
+ document.querySelector('[data-action="dm-delete"]')?.addEventListener('click', () => {
+ const code = this._dmCtxMenuCode;
+ if (!code) return;
+ this._closeDmCtxMenu();
+ if (!confirm('⚠️ Delete this DM?\nAll messages will be permanently deleted for both users.')) return;
+ this.socket.emit('delete-dm', { code });
+ });
+
+ // Close on outside click
+ document.addEventListener('click', (e) => {
+ if (this._dmCtxMenuEl && !this._dmCtxMenuEl.contains(e.target) && !e.target.closest('.dm-more-btn')) {
+ this._closeDmCtxMenu();
+ }
+ });
+ }
+
+ _openDmCtxMenu(code, anchorEl, mouseEvent) {
+ this._dmCtxMenuCode = code;
+ const menu = this._dmCtxMenuEl;
+ if (!menu) return;
+
+ // Update mute label
+ const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
+ const muteBtn = menu.querySelector('[data-action="dm-mute"]');
+ if (muteBtn) muteBtn.textContent = muted.includes(code) ? '🔕 Unmute DM' : '🔔 Mute DM';
+
+ // Position
+ if (mouseEvent) {
+ menu.style.top = mouseEvent.clientY + 'px';
+ menu.style.left = mouseEvent.clientX + 'px';
+ } else {
+ const rect = anchorEl.getBoundingClientRect();
+ menu.style.top = rect.bottom + 4 + 'px';
+ menu.style.left = rect.left + 'px';
+ }
+ menu.style.display = 'block';
+
+ // Keep inside viewport
+ requestAnimationFrame(() => {
+ const mr = menu.getBoundingClientRect();
+ if (mr.right > window.innerWidth) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
+ if (mr.bottom > window.innerHeight) menu.style.top = (mr.top - mr.height - 4) + 'px';
+ });
+ }
+
+ _closeDmCtxMenu() {
+ if (this._dmCtxMenuEl) this._dmCtxMenuEl.style.display = 'none';
+ this._dmCtxMenuCode = null;
+ }
+
/* ── Organize sub-channels modal ─────────────────────── */
_openOrganizeModal(parentCode, serverLevel) {
@@ -4522,7 +4842,7 @@ class HavenApp {
});
}
- const count = this.unreadCounts[ch.code] || ch.unreadCount || 0;
+ const count = (ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0);
if (count > 0) {
const badge = document.createElement('span');
badge.className = 'channel-badge';
@@ -4531,6 +4851,12 @@ class HavenApp {
}
el.addEventListener('click', () => this.switchChannel(ch.code));
+ // Right-click to open context menu
+ el.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ const btn = el.querySelector('.channel-more-btn');
+ if (btn) this._openChannelCtxMenu(ch.code, btn);
+ });
return el;
};
@@ -4649,7 +4975,7 @@ class HavenApp {
if (dmCollapsed) dmList.style.display = 'none';
// Update unread badge
- const totalUnread = dmChannels.reduce((sum, ch) => sum + (this.unreadCounts[ch.code] || ch.unreadCount || 0), 0);
+ const totalUnread = dmChannels.reduce((sum, ch) => sum + ((ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0)), 0);
const badge = document.getElementById('dm-unread-badge');
if (badge) {
if (totalUnread > 0) {
@@ -4703,13 +5029,29 @@ class HavenApp {
@
${this._escapeHtml(dmName)}
`;
- const count = this.unreadCounts[ch.code] || ch.unreadCount || 0;
+ const count = (ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0);
if (count > 0) {
const bdg = document.createElement('span');
bdg.className = 'channel-badge';
bdg.textContent = count > 99 ? '99+' : count;
el.appendChild(bdg);
}
+ // "..." more button for DM context menu
+ const moreBtn = document.createElement('button');
+ moreBtn.className = 'channel-more-btn dm-more-btn';
+ moreBtn.textContent = '⋯';
+ moreBtn.title = 'More options';
+ moreBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._openDmCtxMenu(ch.code, moreBtn);
+ });
+ el.appendChild(moreBtn);
+ // Right-click context menu
+ el.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._openDmCtxMenu(ch.code, el, e);
+ });
el.addEventListener('click', () => this.switchChannel(ch.code));
return el;
};
@@ -7645,6 +7987,15 @@ class HavenApp {
// Render spoilers (||text||) — CSP-safe, uses delegated click handler
html = html.replace(/\|\|(.+?)\|\|/g, '
$1');
+ // 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 `

`;
+ return match;
+ });
+ }
+
// Render /me action text (italic)
if (html.startsWith('_') && html.endsWith('_') && html.length > 2) {
html = `
${html.slice(1, -1)}`;
@@ -7753,11 +8104,18 @@ class HavenApp {
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':'❤️' };
- for (const cat of Object.keys(this.emojiCategories)) {
+ 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);
@@ -7793,9 +8151,15 @@ class HavenApp {
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 = self.emojiCategories[self._emojiActiveCategory] || self.emojis;
+ emojis = allCategories[self._emojiActiveCategory] || self.emojis;
}
if (filter && emojis.length === 0) {
grid.innerHTML = '
No emoji found
';
@@ -7804,7 +8168,18 @@ class HavenApp {
emojis.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'emoji-item';
- btn.textContent = emoji;
+ // 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 = `

`;
+ } else {
+ btn.textContent = emoji;
+ }
+ } else {
+ btn.textContent = emoji;
+ }
btn.addEventListener('click', () => {
const input = document.getElementById('message-input');
const start = input.selectionStart;
@@ -8081,7 +8456,14 @@ class HavenApp {
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(', ');
- return `
`;
+ // 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 = `

`;
+ }
+ return `
`;
}).join('');
return `
${badges}
`;
@@ -8110,18 +8492,163 @@ class HavenApp {
if (wasAtBottom) this._scrollToBottom();
}
+ _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 = 'Customize Quick Reactions';
+ 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 = 'Click a slot, then pick an emoji to replace it.';
+ 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 = `

`;
+ 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 = 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 = '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 = `

`;
+ 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 = 'Done';
+ doneBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ editor.remove();
+ });
+ editor.appendChild(doneBtn);
+
+ msgEl.appendChild(editor);
+ }
+
_showReactionPicker(msgEl, msgId) {
// Remove any existing reaction 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());
const picker = document.createElement('div');
picker.className = 'reaction-picker';
- const quickEmojis = ['👍','👎','😂','❤️','🔥','💯','😮','😢'];
+ const quickEmojis = this._getQuickEmojis();
quickEmojis.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'reaction-pick-btn';
- btn.textContent = emoji;
+ // 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 = `

`;
+ else btn.textContent = emoji;
+ } else {
+ btn.textContent = emoji;
+ }
btn.addEventListener('click', () => {
this.socket.emit('add-reaction', { messageId: msgId, emoji });
picker.remove();
@@ -8140,11 +8667,27 @@ class HavenApp {
});
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 = 'Customize quick reactions';
+ gearBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._showQuickEmojiEditor(picker, msgEl, msgId);
+ });
+ picker.appendChild(gearBtn);
+
msgEl.appendChild(picker);
// Close on click outside
const close = (e) => {
- if (!picker.contains(e.target) && !e.target.closest('.reaction-full-picker')) {
+ if (!picker.contains(e.target) && !e.target.closest('.reaction-full-picker') && !e.target.closest('.quick-emoji-editor')) {
picker.remove();
document.querySelectorAll('.reaction-full-picker').forEach(el => el.remove());
document.removeEventListener('click', close);
@@ -8207,6 +8750,34 @@ class HavenApp {
});
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 = '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 = `

`;
+ btn.addEventListener('click', () => {
+ this.socket.emit('add-reaction', { messageId: msgId, emoji: `:${ce.name}:` });
+ panel.remove();
+ quickPicker.remove();
+ });
+ row.appendChild(btn);
+ });
+ grid.appendChild(row);
+ }
+ }
};
renderAll('');
@@ -8787,6 +9358,13 @@ class HavenApp {
}).join('');
}
+ _syncSettingsNav() {
+ const isAdmin = document.getElementById('admin-mod-panel')?.style.display !== 'none';
+ document.querySelectorAll('.settings-nav-admin').forEach(el => {
+ el.style.display = isAdmin ? '' : 'none';
+ });
+ }
+
_snapshotAdminSettings() {
this._adminSnapshot = {
server_name: this.serverSettings.server_name || 'HAVEN',
diff --git a/server.js b/server.js
index c8cf6bc..b63e5f3 100644
--- a/server.js
+++ b/server.js
@@ -694,6 +694,68 @@ app.delete('/api/sounds/:name', (req, res) => {
} catch { res.status(500).json({ error: 'Failed to delete sound' }); }
});
+// ── Custom emoji upload (admin only, image, max 256 KB) ──
+const emojiUpload = multer({
+ storage: uploadStorage,
+ limits: { fileSize: 256 * 1024 },
+ fileFilter: (req, file, cb) => {
+ if (/^image\/(png|gif|webp|jpeg)$/.test(file.mimetype)) cb(null, true);
+ else cb(new Error('Only images allowed (png, gif, webp, jpg)'));
+ }
+});
+
+app.post('/api/upload-emoji', uploadLimiter, (req, res) => {
+ const token = req.headers.authorization?.split(' ')[1];
+ const user = token ? verifyToken(token) : null;
+ if (!user) return res.status(401).json({ error: 'Unauthorized' });
+ if (!user.isAdmin) return res.status(403).json({ error: 'Admin only' });
+
+ emojiUpload.single('emoji')(req, res, (err) => {
+ if (err) return res.status(400).json({ error: err.message });
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
+
+ let name = (req.body.name || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
+ if (!name) name = path.basename(req.file.filename, path.extname(req.file.filename));
+ if (name.length > 30) name = name.slice(0, 30);
+
+ const { getDb } = require('./src/database');
+ try {
+ getDb().prepare(
+ 'INSERT OR REPLACE INTO custom_emojis (name, filename, uploaded_by) VALUES (?, ?, ?)'
+ ).run(name, req.file.filename, user.id);
+ res.json({ name, url: `/uploads/${req.file.filename}` });
+ } catch { res.status(500).json({ error: 'Failed to save emoji' }); }
+ });
+});
+
+app.get('/api/emojis', (req, res) => {
+ const token = req.headers.authorization?.split(' ')[1];
+ const user = token ? verifyToken(token) : null;
+ if (!user) return res.status(401).json({ error: 'Unauthorized' });
+ const { getDb } = require('./src/database');
+ try {
+ const emojis = getDb().prepare('SELECT name, filename FROM custom_emojis ORDER BY name').all();
+ res.json({ emojis: emojis.map(e => ({ name: e.name, url: `/uploads/${e.filename}` })) });
+ } catch { res.json({ emojis: [] }); }
+});
+
+app.delete('/api/emojis/:name', (req, res) => {
+ const token = req.headers.authorization?.split(' ')[1];
+ const user = token ? verifyToken(token) : null;
+ if (!user) return res.status(401).json({ error: 'Unauthorized' });
+ if (!user.isAdmin) return res.status(403).json({ error: 'Admin only' });
+ const name = req.params.name;
+ const { getDb } = require('./src/database');
+ try {
+ const row = getDb().prepare('SELECT filename FROM custom_emojis WHERE name = ?').get(name);
+ if (row) {
+ try { fs.unlinkSync(path.join(uploadDir, row.filename)); } catch {}
+ getDb().prepare('DELETE FROM custom_emojis WHERE name = ?').run(name);
+ }
+ res.json({ ok: true });
+ } catch { res.status(500).json({ error: 'Failed to delete emoji' }); }
+});
+
// ── GIF search proxy (GIPHY API — keeps key server-side) ──
function getGiphyKey() {
// Check database first (set via admin panel), fall back to .env
diff --git a/src/database.js b/src/database.js
index 1da6957..6450953 100644
--- a/src/database.js
+++ b/src/database.js
@@ -229,6 +229,17 @@ function initDatabase() {
);
`);
+ // ── Migration: custom_emojis table (admin-uploaded server emojis) ──
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS custom_emojis (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ filename TEXT NOT NULL,
+ uploaded_by INTEGER REFERENCES users(id),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+
// ── Migration: channel topic column ─────────────────────
try {
db.prepare("SELECT topic FROM channels LIMIT 0").get();
diff --git a/src/socketHandlers.js b/src/socketHandlers.js
index df59f9e..1b777ad 100644
--- a/src/socketHandlers.js
+++ b/src/socketHandlers.js
@@ -352,6 +352,13 @@ function setupSocketHandlers(io, db) {
// Refresh display_name, avatar AND is_admin from DB (JWT may be stale)
try {
const uRow = db.prepare('SELECT display_name, is_admin, username, avatar, avatar_shape FROM users WHERE id = ?').get(user.id);
+
+ // Identity cross-check: reject if the DB user_id now belongs to a different account
+ // (happens when the database is reset/recreated and IDs get reassigned)
+ if (!uRow || uRow.username !== user.username) {
+ return next(new Error('Session expired'));
+ }
+
socket.user.displayName = (uRow && uRow.display_name) ? uRow.display_name : user.username;
socket.user.avatar = (uRow && uRow.avatar) ? uRow.avatar : null;
socket.user.avatar_shape = (uRow && uRow.avatar_shape) ? uRow.avatar_shape : 'circle';
@@ -1637,11 +1644,20 @@ function setupSocketHandlers(io, db) {
socket.on('add-reaction', (data) => {
if (!data || typeof data !== 'object') return;
- if (!isInt(data.messageId) || !isString(data.emoji, 1, 8)) return;
+ if (!isInt(data.messageId) || !isString(data.emoji, 1, 32)) return;
- // Verify the emoji is a real emoji (allow compound emojis, skin tones, ZWJ sequences)
+ // Verify the emoji is a real emoji or a custom server emoji (:name:)
const allowed = /^[\p{Emoji}\p{Emoji_Component}\uFE0F\u200D]+$/u;
- if (!allowed.test(data.emoji) || data.emoji.length > 16) return;
+ const customEmojiPattern = /^:[a-zA-Z0-9_-]{1,30}:$/;
+ if (!allowed.test(data.emoji) && !customEmojiPattern.test(data.emoji)) return;
+ if (data.emoji.length > 32) return;
+
+ // If custom emoji, verify it exists
+ if (customEmojiPattern.test(data.emoji)) {
+ const emojiName = data.emoji.slice(1, -1).toLowerCase();
+ const exists = db.prepare('SELECT 1 FROM custom_emojis WHERE name = ?').get(emojiName);
+ if (!exists) return;
+ }
const code = socket.currentChannel;
if (!code) return;
@@ -1674,7 +1690,7 @@ function setupSocketHandlers(io, db) {
socket.on('remove-reaction', (data) => {
if (!data || typeof data !== 'object') return;
- if (!isInt(data.messageId) || !isString(data.emoji, 1, 8)) return;
+ if (!isInt(data.messageId) || !isString(data.emoji, 1, 32)) return;
const code = socket.currentChannel;
if (!code) return;
@@ -3268,6 +3284,37 @@ function setupSocketHandlers(io, db) {
}
});
+ // ═══════════════ DELETE DM ══════════════════════════════
+
+ socket.on('delete-dm', (data) => {
+ if (!data || typeof data !== 'object') return;
+ const code = typeof data.code === 'string' ? data.code.trim() : '';
+ if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
+
+ const channel = db.prepare('SELECT * FROM channels WHERE code = ? AND is_dm = 1').get(code);
+ if (!channel) return socket.emit('error-msg', 'DM not found');
+
+ // Allow if user is a member of this DM or is admin
+ const isMember = db.prepare('SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?').get(channel.id, socket.user.id);
+ if (!isMember && !socket.user.isAdmin) {
+ return socket.emit('error-msg', 'Not authorized');
+ }
+
+ const deleteAll = db.transaction((chId) => {
+ db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(chId);
+ db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(chId);
+ db.prepare('DELETE FROM messages WHERE channel_id = ?').run(chId);
+ db.prepare('DELETE FROM read_positions WHERE channel_id = ?').run(chId);
+ db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(chId);
+ db.prepare('DELETE FROM channels WHERE id = ?').run(chId);
+ });
+ deleteAll(channel.id);
+
+ io.to(`channel:${code}`).emit('channel-deleted', { code });
+ channelUsers.delete(code);
+ console.log(`🗑️ DM ${code} deleted by ${socket.user.username}`);
+ });
+
// ═══════════════ READ POSITIONS ════════════════════════
socket.on('mark-read', (data) => {