v1.9.1: custom emojis, DM deletion, settings nav, reply scroll, quickbar fixes

This commit is contained in:
ancsemi 2026-02-18 02:16:21 -05:00
parent ee76c65a0d
commit 6473ef5142
10 changed files with 1173 additions and 105 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
# Force Unix line endings for shell scripts so Docker builds
# work regardless of the user's core.autocrlf setting.
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
Dockerfile text eol=lf

View file

@ -11,6 +11,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
---
## [1.9.1] — 2026-02-18
### Added
- **Custom server emojis** — admins can upload PNG/GIF/WebP images as custom emojis (`:emoji_name:` syntax). Works in messages, reactions, and the emoji picker.
- **Emoji quickbar customization** — click the ⚙️ gear icon on the reaction picker to swap any of the 8 quick-react slots with any emoji (including custom ones). Saved per-user in localStorage.
- **DM deletion** — right-click (or click "...") on any DM conversation to delete it. Removes from your sidebar only.
- **Reply banner click-to-scroll** — clicking the reply preview above a message now smooth-scrolls to the original message and highlights it briefly.
- **Settings navigation sidebar** — the settings modal now has a left-side index with clickable categories (Layout, Sounds, Push, Password, and all admin subsections). Hidden on mobile.
- **Popout modals for sounds & emojis** — Custom Sounds and Custom Emojis management moved out of the inline settings panel into their own dedicated modals (like Bots/Roles). Keeps the settings menu lean.
- **JWT identity cross-check** — tokens are now validated against the actual database user, preventing token reuse across accounts (security hardening).
### Fixed
- **Docker entrypoint CRLF crash** — added `.gitattributes` to force LF line endings on shell scripts, plus a `sed` fallback in the Dockerfile.
- **Quick emoji editor immediately closing** — click events inside the editor propagated to the document-level close handler. Added `stopPropagation()` to all interactive elements.
- **Gear icon placement** — moved the ⚙️ customization button to the right of the "⋯" more-emojis button so frequent "..." clicks aren't blocked.
---
## [1.9.0] — 2026-02-17
### Added

View file

@ -1,53 +1,53 @@
# ── Haven Dockerfile ─────────────────────────────────────
# Lightweight Node.js image with SSL cert auto-generation.
# Data (database, .env, certs, uploads) is stored in /data
# so it survives container rebuilds.
#
# Build: docker build -t haven .
# Run: docker compose up -d
# ─────────────────────────────────────────────────────────
FROM node:20-alpine
# OpenSSL → auto-generate self-signed HTTPS certs
# tini → proper PID 1 signal handling (clean shutdown)
# su-exec → drop root to 'node' user after entrypoint setup
# build-base, python3 → compile native modules (better-sqlite3)
RUN apk update && apk add --no-cache \
openssl tini su-exec \
build-base python3
WORKDIR /app
# Install dependencies first (layer caching — only re-runs when package.json changes)
COPY package*.json ./
RUN npm ci --omit=dev && \
# Remove build tools after native modules are compiled (saves ~150 MB)
apk del build-base python3 && \
rm -rf /root/.cache /tmp/*
# Copy entrypoint (auto-generates SSL certs, fixes volume permissions)
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy application source
COPY . .
# ── Environment defaults (override via docker-compose.yml or .env) ──
ENV PORT=3000 \
HOST=0.0.0.0 \
HAVEN_DATA_DIR=/data \
NODE_ENV=production
# Create data directory; give ownership to non-root 'node' user
RUN mkdir -p /data/certs /data/uploads && chown -R node:node /app /data
USER root
EXPOSE 3000 3001
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- --no-check-certificate https://127.0.0.1:${PORT:-3000}/api/health || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
# ── Haven Dockerfile ─────────────────────────────────────
# Lightweight Node.js image with SSL cert auto-generation.
# Data (database, .env, certs, uploads) is stored in /data
# so it survives container rebuilds.
#
# Build: docker build -t haven .
# Run: docker compose up -d
# ─────────────────────────────────────────────────────────
FROM node:20-alpine
# OpenSSL → auto-generate self-signed HTTPS certs
# tini → proper PID 1 signal handling (clean shutdown)
# su-exec → drop root to 'node' user after entrypoint setup
# build-base, python3 → compile native modules (better-sqlite3)
RUN apk update && apk add --no-cache \
openssl tini su-exec \
build-base python3
WORKDIR /app
# Install dependencies first (layer caching — only re-runs when package.json changes)
COPY package*.json ./
RUN npm ci --omit=dev && \
# Remove build tools after native modules are compiled (saves ~150 MB)
apk del build-base python3 && \
rm -rf /root/.cache /tmp/*
# Copy entrypoint (auto-generates SSL certs, fixes volume permissions)
COPY docker-entrypoint.sh /entrypoint.sh
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
# Copy application source
COPY . .
# ── Environment defaults (override via docker-compose.yml or .env) ──
ENV PORT=3000 \
HOST=0.0.0.0 \
HAVEN_DATA_DIR=/data \
NODE_ENV=production
# Create data directory; give ownership to non-root 'node' user
RUN mkdir -p /data/certs /data/uploads && chown -R node:node /app /data
USER root
EXPOSE 3000 3001
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- --no-check-certificate https://127.0.0.1:${PORT:-3000}/api/health || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
CMD ["node", "server.js"]

View file

@ -1,6 +1,6 @@
{
"name": "haven",
"version": "1.9.0",
"version": "1.9.1",
"description": "Self-hosted private chat — your server, your rules",
"license": "MIT-NC",
"main": "server.js",

View file

@ -29,6 +29,9 @@
<button class="server-icon add-server" id="add-server-btn" title="Add Server">
<span class="server-icon-text">+</span>
</button>
<button class="server-icon manage-servers" id="manage-servers-btn" title="Manage Servers">
<span class="server-icon-text"></span>
</button>
</nav>
<!-- ─── Left Sidebar ────────────────────────────── -->
@ -711,8 +714,31 @@
<button class="settings-close-btn" id="close-settings-btn">&times;</button>
</div>
<div class="settings-layout">
<nav class="settings-nav" id="settings-nav">
<div class="settings-nav-group">User</div>
<div class="settings-nav-item active" data-target="section-density">📐 Layout</div>
<div class="settings-nav-item" data-target="section-sounds">🔔 Sounds</div>
<div class="settings-nav-item" data-target="section-push">📲 Push</div>
<div class="settings-nav-item" data-target="section-password">🔒 Password</div>
<div class="settings-nav-group settings-nav-admin" style="display:none">Admin</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-branding" style="display:none">🏠 Branding</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-members" style="display:none">👥 Members</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-whitelist" style="display:none">🛡️ Whitelist</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-invite" style="display:none">🌐 Invite Code</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-cleanup" style="display:none">🗑️ Cleanup</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-uploads" style="display:none">📁 Uploads</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-sounds-admin" style="display:none">🔊 Sounds</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-emojis" style="display:none">😎 Emojis</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-roles" style="display:none">👑 Roles</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-tunnel" style="display:none">🧭 Tunnel</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-bots" style="display:none">🤖 Bots</div>
<div class="settings-nav-item settings-nav-admin" data-target="section-modmode" style="display:none">🔧 Mod Mode</div>
</nav>
<div class="settings-body">
<!-- Layout Density Section -->
<div class="settings-section">
<div class="settings-section" id="section-density">
<h5 class="settings-section-title">📐 Layout Density</h5>
<div class="density-picker" id="density-picker">
<button type="button" class="density-btn" data-density="compact" title="Compact — tighter spacing, smaller avatars">
@ -731,7 +757,7 @@
</div>
<!-- Sounds Section -->
<div class="settings-section">
<div class="settings-section" id="section-sounds">
<h5 class="settings-section-title">🔔 Sounds</h5>
<div class="notif-settings">
<label class="toggle-row">
@ -799,7 +825,7 @@
</div>
<!-- Push Notifications Section -->
<div class="settings-section">
<div class="settings-section" id="section-push">
<h5 class="settings-section-title">📲 Push Notifications</h5>
<div class="notif-settings">
<label class="toggle-row">
@ -812,7 +838,7 @@
</div>
<!-- Password Change Section -->
<div class="settings-section">
<div class="settings-section" id="section-password">
<h5 class="settings-section-title">🔒 Password</h5>
<div class="password-change-form">
<div class="form-group compact">
@ -834,7 +860,7 @@
<h5 class="settings-section-title">🛡️ Admin</h5>
<!-- Server Branding -->
<div class="admin-settings">
<div class="admin-settings" id="section-branding">
<h5 class="settings-section-subtitle">🏠 Server Branding</h5>
<label class="select-row">
<span>Server Name</span>
@ -855,7 +881,7 @@
</div>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-members" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<label class="select-row">
<span>Show Members</span>
<select id="member-visibility-select">
@ -866,8 +892,8 @@
</label>
<button class="btn-sm btn-full" id="view-bans-btn">📋 View Bans</button>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle"><EFBFBD> Whitelist</h5>
<div class="admin-settings" id="section-whitelist" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🛡️ Whitelist</h5>
<label class="toggle-row">
<span>Enabled</span>
<input type="checkbox" id="whitelist-enabled">
@ -883,7 +909,7 @@
</div>
</div>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-invite" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🌐 Server Invite Code</h5>
<small class="settings-hint">A single code that adds people to every channel &amp; sub-channel on the server</small>
<div style="margin-top:8px;">
@ -897,7 +923,7 @@
</div>
</div>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-cleanup" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🗑️ Auto-Cleanup</h5>
<label class="toggle-row">
<span>Enabled</span>
@ -915,30 +941,30 @@
<small class="settings-hint">Trim oldest messages when DB exceeds N MB (0 = off)</small>
<button class="btn-sm btn-full" id="run-cleanup-now-btn" style="margin-top:4px;">🧹 Run Cleanup Now</button>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle"><EFBFBD> File Uploads</h5>
<div class="admin-settings" id="section-uploads" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">📁 File Uploads</h5>
<label class="select-row">
<span>Max Upload Size (MB)</span>
<input type="number" id="max-upload-mb" min="1" max="2048" value="25" class="settings-number-input">
</label>
<small class="settings-hint">Maximum file size users can upload (12048 MB / 2 GB, default 25)</small>
</div>
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle"><EFBFBD>🔊 Custom Sounds</h5>
<small class="settings-hint">Upload audio files for custom notification sounds (max 1 MB each)</small>
<div class="admin-settings" id="section-sounds-admin" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🔊 Custom Sounds</h5>
<small class="settings-hint">Upload audio files for custom notification sounds</small>
<div style="margin-top:8px;">
<div class="whitelist-add-row">
<input type="text" id="sound-name-input" placeholder="Sound name..." maxlength="30" class="settings-text-input">
<input type="file" id="sound-file-input" accept="audio/*" style="max-width:120px;font-size:11px">
<button class="btn-sm btn-accent" id="sound-upload-btn">Upload</button>
</div>
<div id="custom-sounds-list" style="margin-top:8px;">
<p class="muted-text">No custom sounds uploaded</p>
</div>
<button class="btn-sm btn-full btn-accent" id="open-sound-manager-btn">⚙️ Manage Sounds</button>
</div>
</div>
<div class="admin-settings" id="section-emojis" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">😎 Custom Emojis</h5>
<small class="settings-hint">Upload images for custom server emojis</small>
<div style="margin-top:8px;">
<button class="btn-sm btn-full btn-accent" id="open-emoji-manager-btn">⚙️ Manage Emojis</button>
</div>
</div>
<!-- Role Management (Admin only) -->
<div class="admin-settings" id="role-mgmt-section" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-roles" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">👑 Role Management</h5>
<small class="settings-hint">Create and manage roles. Assign roles to users from the user list context menu.</small>
<div style="margin-top:8px;">
@ -949,7 +975,7 @@
</div>
</div>
<!-- Tunnel settings (Admin only) -->
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-tunnel" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🧭 Tunnel</h5>
<label class="toggle-row">
<span>Enabled</span>
@ -965,7 +991,7 @@
<div id="tunnel-status-display" class="muted-text" style="margin-top:4px;font-size:11px;">Inactive</div>
</div>
<!-- Webhooks / Bots (Admin only) -->
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-bots" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🤖 Webhooks / Bots</h5>
<small class="settings-hint">Create and manage webhook bots that can post messages to channels.</small>
<div style="margin-top:8px;">
@ -976,7 +1002,7 @@
</div>
</div>
<!-- Mod Mode (Admin only) -->
<div class="admin-settings" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<div class="admin-settings" id="section-modmode" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🔧 Mod Mode</h5>
<small class="settings-hint">Rearrange sidebar sections and snap server/sidebar/status panels</small>
<button class="btn-sm btn-full" id="mod-mode-settings-toggle" style="margin-top:6px;">🔧 Toggle Mod Mode</button>
@ -988,6 +1014,8 @@
<small class="settings-hint" style="text-align:center;margin-top:4px">Changes only apply when you click Save. Close (✕) to cancel.</small>
</div>
</div>
</div><!-- /settings-body -->
</div><!-- /settings-layout -->
</div>
</div>
@ -1052,6 +1080,44 @@
</div>
</div>
<!-- Sound Management Modal -->
<div class="modal-overlay" id="sound-modal" style="display:none">
<div class="modal" style="max-width:520px">
<h3>🔊 Sound Management</h3>
<small class="settings-hint" style="display:block;margin-bottom:12px">Upload audio files for custom notification sounds (max 1 MB each)</small>
<div class="whitelist-add-row">
<input type="text" id="sound-name-input" placeholder="Sound name..." maxlength="30" class="settings-text-input">
<input type="file" id="sound-file-input" accept="audio/*" style="max-width:120px;font-size:11px">
<button class="btn-sm btn-accent" id="sound-upload-btn">Upload</button>
</div>
<div id="custom-sounds-list" style="margin-top:12px;">
<p class="muted-text">No custom sounds uploaded</p>
</div>
<div class="modal-actions">
<button class="btn-sm" id="close-sound-modal-btn">Close</button>
</div>
</div>
</div>
<!-- Emoji Management Modal -->
<div class="modal-overlay" id="emoji-modal" style="display:none">
<div class="modal" style="max-width:520px">
<h3>😎 Emoji Management</h3>
<small class="settings-hint" style="display:block;margin-bottom:12px">Upload images for custom server emojis (max 256 KB, png/gif/webp). Name must be lowercase, no spaces.</small>
<div class="whitelist-add-row">
<input type="text" id="emoji-name-input" placeholder="emoji_name" maxlength="30" class="settings-text-input">
<input type="file" id="emoji-file-input" accept="image/png,image/gif,image/webp,image/jpeg" style="max-width:120px;font-size:11px">
<button class="btn-sm btn-accent" id="emoji-upload-btn">Upload</button>
</div>
<div id="custom-emojis-list" style="margin-top:12px;">
<p class="muted-text">No custom emojis uploaded</p>
</div>
<div class="modal-actions">
<button class="btn-sm" id="close-emoji-modal-btn">Close</button>
</div>
</div>
</div>
<div class="modal-overlay" id="bans-modal" style="display:none">
<div class="modal modal-wide">
<h3>Banned Users</h3>
@ -1086,9 +1152,24 @@
</div>
</div>
<!-- Manage Servers Modal -->
<div class="modal-overlay" id="manage-servers-modal" style="display:none">
<div class="modal" style="max-width:480px">
<h3>⚙ Manage Servers</h3>
<p class="modal-desc">Edit or remove your linked servers</p>
<div id="manage-servers-list" class="manage-servers-list"></div>
<div class="modal-actions" style="justify-content:space-between">
<button class="btn-sm btn-accent" id="manage-servers-add-btn">+ Add Server</button>
<button class="btn-sm" id="manage-servers-close-btn">Close</button>
</div>
</div>
</div>
<!-- Channel context menu (appears on "..." hover button) -->
<div id="channel-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="mute">🔔 Mute Channel</button>
<button class="channel-ctx-item" data-action="join-voice">🎙️ Join Voice</button>
<button class="channel-ctx-item" data-action="leave-voice" style="display:none">📴 Disconnect Voice</button>
<hr class="channel-ctx-sep">
<button class="channel-ctx-item mod-only" data-action="rename-channel">✏️ Rename Channel</button>
<button class="channel-ctx-item mod-only" data-action="create-sub-channel">📁 Create Sub-channel</button>
@ -1103,6 +1184,13 @@
<button class="channel-ctx-item admin-only danger" data-action="delete">🗑️ Delete Channel</button>
</div>
<!-- DM context menu -->
<div id="dm-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="dm-mute">🔔 Mute DM</button>
<hr class="channel-ctx-sep">
<button class="channel-ctx-item danger" data-action="dm-delete">🗑️ Delete DM</button>
</div>
<!-- Organize Sub-channels Modal -->
<div class="modal-overlay" id="organize-modal" style="display:none">
<div class="modal" style="max-width:500px">

View file

@ -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;
}

View file

@ -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
? `<img src="${this._escapeHtml(iconUrl)}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initial}'">`
: initial;
row.innerHTML = `
<div class="manage-server-icon">${iconContent}</div>
<div class="manage-server-info">
<div class="manage-server-name">${this._escapeHtml(s.name)}</div>
<div class="manage-server-url">${this._escapeHtml(s.url)}</div>
</div>
<span class="manage-server-status ${statusClass}">${statusText}</span>
<div class="manage-server-actions">
<button class="manage-server-visit" title="Open in new tab">🔗</button>
<button class="manage-server-edit" title="Edit server"></button>
<button class="manage-server-delete danger-action" title="Remove server">🗑</button>
</div>
`;
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 = '<p class="muted-text">No custom emojis uploaded</p>';
return;
}
list.innerHTML = emojis.map(e => `
<div class="custom-sound-item">
<img src="${this._escapeHtml(e.url)}" alt=":${this._escapeHtml(e.name)}:" class="custom-emoji-preview" style="width:24px;height:24px;vertical-align:middle;margin-right:6px;">
<span class="custom-sound-name">:${this._escapeHtml(e.name)}:</span>
<button class="btn-xs emoji-delete-btn" data-name="${this._escapeHtml(e.name)}" title="Delete">🗑</button>
</div>
`).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 {
<span class="channel-hash">@</span>
<span class="channel-name">${this._escapeHtml(dmName)}</span>
`;
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, '<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="${emoji.url}" alt=":${name}:" title=":${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>`;
@ -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 = '<p class="muted-text" style="padding:12px;font-size:12px;width:100%;text-align:center">No emoji found</p>';
@ -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 = `<img src="${ce.url}" alt=":${ce.name}:" title=":${ce.name}:" class="custom-emoji">`;
} 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 `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${g.emoji}" title="${names}">${g.emoji} ${g.users.length}</button>`;
// 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="${ce.url}" alt=":${ce.name}:" class="custom-emoji reaction-custom-emoji">`;
}
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
}).join('');
return `<div class="reactions-row">${badges}</div>`;
@ -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 = `<img src="${ce.url}" alt="${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 = 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 = `<img src="${ce.url}" alt=":${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 = '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 = `<img src="${ce.url}" alt="${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();
@ -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 = `<img src="${ce.url}" alt=":${ce.name}:" title=":${ce.name}:" class="custom-emoji">`;
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',

View file

@ -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

View file

@ -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();

View file

@ -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) => {