mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
v1.9.1: custom emojis, DM deletion, settings nav, reply scroll, quickbar fixes
This commit is contained in:
parent
ee76c65a0d
commit
6473ef5142
10 changed files with 1173 additions and 105 deletions
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal 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
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
104
Dockerfile
104
Dockerfile
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
142
public/app.html
142
public/app.html
|
|
@ -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">×</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 & 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 (1–2048 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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
602
public/js/app.js
602
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
|
||||
? `<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',
|
||||
|
|
|
|||
62
server.js
62
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue