v2.7.8 — upload progress bar, view all members perm, notification nav fix, stream reopen, Docker update fix

This commit is contained in:
ancsemi 2026-03-16 21:54:49 -04:00
parent b44cca07a0
commit fb58245f50
17 changed files with 398 additions and 98 deletions

View file

@ -11,6 +11,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
---
## [2.7.8] — 2026-03-16
### Added
- **File upload progress bar** — a progress bar now appears above the message input during file and image uploads showing the real-time upload percentage.
- **View All Members permission** — new `view_all_members` permission that lets roles see all server members in the sidebar and member list, regardless of shared channels. Granted to Server Mod by default. Configurable per-role in admin settings.
### Fixed
- **Desktop notification click not navigating** — clicking a native OS notification in the Haven Desktop app now opens the app and switches to the correct channel or DM.
- **Stream close button now allows reopening** — the ✕ button on stream tiles now hides and mutes the stream instead of permanently removing it. Hidden streams can be restored via the "🖥 N streams hidden" bar, the ⋯ menu on the streamer's name, or by clicking their 🔴 LIVE badge.
- **Docker update instructions**`docker-compose.yml` now defaults to the pre-built image (`ghcr.io/ancsemi/haven:latest`), fixing the issue where `docker compose up -d` would rebuild from source rather than use the pulled image. Update instructions updated throughout.
---
## [2.7.7] — 2026-03-16
### Added

View file

@ -58,6 +58,13 @@ Replace the `haven_data:/data` line in `docker-compose.yml`.
### Updating
**Option A — Pre-built image** (default, recommended):
```bash
docker compose pull
docker compose up -d --force-recreate
```
**Option B — Built from source** (only if you uncommented `build: .`):
```bash
git pull
docker compose build --no-cache
@ -66,6 +73,18 @@ docker compose up -d
Your data is safe — it lives in the volume, not the container.
### Checking Your Version
Open this URL in your browser (replace with your domain/IP if needed):
```
https://localhost:3000/api/version
```
Or from inside the container:
```bash
docker compose exec haven cat /app/package.json | grep '"version"'
```
### Linux Prerequisites
If you're on Linux (Ubuntu, Mint, Debian, etc.), make sure you have Docker's official packages installed — the default `docker.io` package from some distros may be missing Compose V2.

View file

@ -74,23 +74,42 @@ Your entire Discord history, now on a server you own. No one can delete it, no o
## Quick Start — Docker (Recommended)
**Option A — Pre-built image** (fastest):
**Option A — Pre-built image** (fastest, easiest updates):
```bash
docker pull ghcr.io/ancsemi/haven:latest
docker run -d -p 3000:3000 -v haven_data:/data ghcr.io/ancsemi/haven:latest
```
**Option B — Build from source**:
Or with Docker Compose (recommended):
```bash
git clone https://github.com/ancsemi/Haven.git
cd Haven
docker compose up -d
```
The shipped `docker-compose.yml` uses the pre-built image by default.
**Option B — Build from source** (only if you need to modify the code):
```bash
git clone https://github.com/ancsemi/Haven.git
cd Haven
```
Uncomment `build: .` in `docker-compose.yml`, then:
```bash
docker compose up -d
```
Open `https://localhost:3000` → Register with username `admin` → Create a channel → Share the code with friends. Done.
> Certificate warning is normal — click **Advanced → Proceed**. Haven uses a self-signed cert for encryption.
**Updating** — if using the pre-built image (default):
```bash
docker compose pull
docker compose up -d --force-recreate
```
**Check your version**: visit `https://localhost:3000/api/version` in your browser.
**Option C — One-click cloud deploy** (Zeabur):
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates?repoURL=https://github.com/ancsemi/Haven)

View file

@ -2,14 +2,14 @@
# Quick start: docker compose up -d
# Stop: docker compose down
# View logs: docker compose logs -f haven
# Update: docker compose build --no-cache && docker compose up -d
# Update: docker compose pull && docker compose up -d --force-recreate
# Shell: docker compose exec haven sh
# ─────────────────────────────────────────────────────────
services:
haven:
build: .
image: haven:latest
image: ghcr.io/ancsemi/haven:latest
# build: . # Uncomment to build from source instead of using the pre-built image
container_name: haven
ports:
- "${PORT:-3000}:${PORT:-3000}" # Main HTTPS port

View file

@ -894,13 +894,13 @@
<span class="discord-feat">🖥️ Windows &amp; Linux</span>
</div>
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/Haven-Setup-1.1.4.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/Haven-Setup-1.1.5.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Windows Installer
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/Haven-1.1.4.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/Haven-1.1.5.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux AppImage
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/haven-desktop_1.1.4_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/haven-desktop_1.1.5_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1365,12 +1365,12 @@
</div>
<div class="download-card fade-in">
<h2>⬡ Haven Server &mdash; v2.7.7</h2>
<h2>⬡ Haven Server &mdash; v2.7.8</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip" class="btn btn-primary download-main">
<span class="icon"></span> Download v2.7.7 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.8.zip" class="btn btn-primary download-main">
<span class="icon"></span> Download v2.7.8 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1387,7 +1387,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.7.7</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v2.7.8</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.8.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.7.7</span> &mdash; Temporary channels, members list privacy, URL @ fix</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip">Download &rarr;</a>
</div>
<div class="version-item">

View file

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

View file

@ -443,6 +443,10 @@
<button class="reply-bar-close" id="reply-close-btn" title="Cancel reply">&times;</button>
</div>
<div id="image-queue-bar" class="image-queue-bar" style="display:none"></div>
<div id="upload-progress-bar" class="upload-progress-bar" style="display:none">
<div class="upload-progress-track"><div class="upload-progress-fill" id="upload-progress-fill"></div></div>
<span class="upload-progress-text" id="upload-progress-text">Uploading...</span>
</div>
<div id="message-input-area" class="message-input-area">
<div class="input-actions-box">
<button id="upload-btn" class="btn-upload" title="Upload File">
@ -1971,6 +1975,10 @@
<span class="cfn-badge cfn-off"></span>
</div>
<div class="cfn-divider"></div>
<div class="cfn-row" data-fn="self-destruct" title="Set a self-destruct timer — channel auto-deletes after the specified hours (0 = off)">
<span class="cfn-label">⏱️ Self Destruct</span>
<span class="cfn-badge cfn-off">OFF</span>
</div>
<div class="cfn-row" data-fn="announcement" title="Announcement channel — messages use a distinct notification sound and gold highlight">
<span class="cfn-label">📢 Announcement</span>
<span class="cfn-badge cfn-off">OFF</span>
@ -2214,6 +2222,16 @@
<input type="checkbox" id="create-sub-private" style="width:16px;height:16px">
<span style="font-size:0.9rem">🔒 Private <span style="opacity:0.5;font-size:0.8rem">(invite-only, hidden from non-members)</span></span>
</label>
<label class="checkbox-row" style="margin:2px 0 8px;display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.9rem">
<input type="checkbox" id="create-sub-temporary" style="width:16px;height:16px">
<span>⏱️ Temporary <span style="opacity:0.5;font-size:0.8rem">(auto-delete)</span></span>
</label>
<div id="sub-temp-duration-row" style="display:none;margin:0 0 8px;padding-left:24px">
<div style="display:flex;gap:6px;align-items:center">
<input type="number" id="create-sub-duration" min="1" max="720" value="24" style="width:60px;font-size:0.8rem" placeholder="Hours">
<span style="font-size:0.75rem;opacity:0.6">hours</span>
</div>
</div>
<p class="muted-text" style="font-size:0.75rem;margin:0 0 8px;opacity:0.6"> Admins always have access to all channels, including private ones.</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn-sm" id="create-sub-cancel-btn">Cancel</button>

View file

@ -6679,6 +6679,35 @@ html.rgb-cycling *::after {
flex-wrap: nowrap;
}
/* ── Upload progress bar ────────────────────────────── */
.upload-progress-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--bg-tertiary);
border-top: 1px solid var(--border);
}
.upload-progress-track {
flex: 1;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
width: 0%;
background: var(--accent);
border-radius: 3px;
transition: width 0.15s ease;
}
.upload-progress-text {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.image-queue-thumb {
position: relative;
width: 64px;
@ -8280,7 +8309,7 @@ html.rgb-cycling *::after {
.collapsible-section-body {
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease, margin 0.25s ease;
max-height: 100px;
max-height: 300px;
opacity: 1;
}

View file

@ -1409,17 +1409,7 @@ _uploadGeneralFile(file) {
const formData = new FormData();
formData.append('file', file);
this._showToast(`Uploading ${file.name}`, 'info');
fetch('/api/upload-file', {
method: 'POST',
headers: { Authorization: `Bearer ${this.token}` },
body: formData
})
.then(r => {
if (!r.ok) return r.text().then(t => { throw new Error(t || `HTTP ${r.status}`); });
return r.json();
})
this._uploadWithProgress('/api/upload-file', formData)
.then(data => {
if (data.error) {
this._showToast(data.error, 'error');
@ -1442,7 +1432,7 @@ _uploadGeneralFile(file) {
this.notifications.play('sent');
this._clearReply();
})
.catch(() => this._showToast('Upload failed', 'error'));
.catch(err => this._showToast(err.message || 'Upload failed', 'error'));
},
_formatFileSize(bytes) {
@ -2269,7 +2259,7 @@ _renderRoleDetail() {
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
'create_channel', 'upload_files', 'use_voice', 'manage_webhooks', 'mention_everyone', 'view_history',
'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
'view_all_members', 'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
];
const permLabels = {
edit_own_messages: 'Edit Own Messages', delete_own_messages: 'Delete Own Messages',
@ -2282,6 +2272,7 @@ _renderRoleDetail() {
upload_files: 'Upload Files', use_voice: 'Use Voice Chat',
manage_webhooks: 'Manage Webhooks', mention_everyone: 'Mention @everyone',
view_history: 'View Message History',
view_all_members: 'View All Server Members',
manage_emojis: 'Manage Custom Emojis',
manage_soundboard: 'Manage Soundboard',
promote_user: 'Promote Users', transfer_admin: 'Transfer Admin'
@ -2683,7 +2674,7 @@ _renderChannelRolesRoleDetail() {
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
'create_channel', 'upload_files', 'use_voice', 'manage_webhooks', 'mention_everyone', 'view_history',
'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
'view_all_members', 'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
];
const permLabels = {
edit_own_messages: 'Edit Own Messages', delete_own_messages: 'Delete Own Messages',
@ -2696,6 +2687,7 @@ _renderChannelRolesRoleDetail() {
upload_files: 'Upload Files', use_voice: 'Use Voice Chat',
manage_webhooks: 'Manage Webhooks', mention_everyone: 'Mention @everyone',
view_history: 'View Message History',
view_all_members: 'View All Server Members',
manage_emojis: 'Manage Custom Emojis',
manage_soundboard: 'Manage Soundboard',
promote_user: 'Promote Users', transfer_admin: 'Transfer Admin'
@ -3080,7 +3072,7 @@ _renderRacConfig() {
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
'create_channel', 'upload_files', 'use_voice', 'manage_webhooks', 'mention_everyone', 'view_history',
'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
'view_all_members', 'manage_emojis', 'manage_soundboard', 'promote_user', 'transfer_admin'
];
// Perms that only admin can grant
const adminOnlyPerms = ['transfer_admin'];
@ -3096,6 +3088,7 @@ _renderRacConfig() {
upload_files: 'Upload Files',
use_voice: 'Use Voice', manage_webhooks: 'Manage Webhooks',
mention_everyone: 'Mention Everyone', view_history: 'View History',
view_all_members: 'View All Members',
manage_emojis: 'Manage Custom Emojis',
manage_soundboard: 'Manage Soundboard',
promote_user: 'Promote Users', transfer_admin: 'Transfer Admin'

View file

@ -321,6 +321,14 @@ _updateChannelFunctionsPanel(ch) {
// Announcement channel
const isAnnouncement = ch.notification_type === 'announcement';
this._setCfnBadge('announcement', isAnnouncement, isAnnouncement ? 'ON' : 'OFF');
// Self Destruct timer
const hasExpiry = !!ch.expires_at;
if (hasExpiry) {
const hoursLeft = Math.max(1, Math.round((new Date(ch.expires_at) - Date.now()) / 3600000));
this._setCfnBadge('self-destruct', true, `${hoursLeft}h`);
} else {
this._setCfnBadge('self-destruct', false, 'OFF');
}
},
_closeChannelCtxMenu() {
@ -1754,7 +1762,7 @@ _fireNativeNotification(message, channelCode) {
// Desktop app: always use native Electron notifications
if (window.havenDesktop?.notify) {
window.havenDesktop.notify(title, body, { silent: true });
window.havenDesktop.notify(title, body, { silent: true, channelCode });
return;
}

View file

@ -325,6 +325,31 @@ _setupUI() {
};
input.addEventListener('keydown', e2 => { if (e2.key === 'Enter') { commitLimit(); input.blur(); } });
input.addEventListener('blur', commitLimit);
} else if (fn === 'self-destruct') {
if (row.querySelector('.cfn-input')) return;
const badge = row.querySelector('.cfn-badge');
if (!badge) return;
const input = document.createElement('input');
input.type = 'number'; input.min = '0'; input.max = '720';
input.value = ''; input.placeholder = '1720h (0=off)'; input.className = 'cfn-input';
input.onclick = e2 => e2.stopPropagation();
badge.replaceWith(input);
input.focus(); input.select();
const commitExpiry = () => {
const hours = parseInt(input.value);
if (isNaN(hours) || hours < 0) return;
if (hours === 0) {
optimistic({ expires_at: null });
this.socket.emit('set-channel-expiry', { code, hours: 0 });
} else {
const clamped = Math.max(1, Math.min(720, hours));
const expiresAt = new Date(Date.now() + clamped * 3600000).toISOString();
optimistic({ expires_at: expiresAt });
this.socket.emit('set-channel-expiry', { code, hours: clamped });
}
};
input.addEventListener('keydown', e2 => { if (e2.key === 'Enter') { commitExpiry(); input.blur(); } });
input.addEventListener('blur', commitExpiry);
}
});
// Move channel up/down
@ -587,6 +612,8 @@ _setupUI() {
// Show the create-sub-channel modal
document.getElementById('create-sub-name').value = '';
document.getElementById('create-sub-private').checked = false;
document.getElementById('create-sub-temporary').checked = false;
document.getElementById('sub-temp-duration-row').style.display = 'none';
document.getElementById('create-sub-parent-name').textContent = `# ${parentCh.name}`;
document.getElementById('create-sub-modal').style.display = 'flex';
document.getElementById('create-sub-modal')._parentCode = code;
@ -597,11 +624,15 @@ _setupUI() {
const modal = document.getElementById('create-sub-modal');
const name = document.getElementById('create-sub-name').value.trim();
const isPrivate = document.getElementById('create-sub-private').checked;
const temporary = document.getElementById('create-sub-temporary').checked;
const duration = parseInt(document.getElementById('create-sub-duration').value) || 24;
if (!name) return;
this.socket.emit('create-sub-channel', {
parentCode: modal._parentCode,
name,
isPrivate
isPrivate,
temporary,
duration
});
modal.style.display = 'none';
});
@ -611,6 +642,14 @@ _setupUI() {
document.getElementById('create-sub-modal')?.addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
});
// Toggle sub-channel temporary duration row
const subTempCheckbox = document.getElementById('create-sub-temporary');
if (subTempCheckbox) {
subTempCheckbox.addEventListener('change', () => {
const durRow = document.getElementById('sub-temp-duration-row');
if (durRow) durRow.style.display = subTempCheckbox.checked ? '' : 'none';
});
}
// Rename channel / sub-channel
document.querySelector('[data-action="rename-channel"]')?.addEventListener('click', async () => {
const code = this._ctxMenuChannel;
@ -3176,6 +3215,54 @@ _saveRename() {
document.getElementById('rename-modal').style.display = 'none';
},
// ── Upload with progress bar ───────────────────────────
_uploadWithProgress(url, formData) {
return new Promise((resolve, reject) => {
const bar = document.getElementById('upload-progress-bar');
const fill = document.getElementById('upload-progress-fill');
const text = document.getElementById('upload-progress-text');
if (bar) { bar.style.display = 'flex'; }
if (fill) { fill.style.width = '0%'; }
if (text) { text.textContent = 'Uploading...'; }
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
if (fill) fill.style.width = pct + '%';
if (text) text.textContent = `${pct}%`;
}
});
xhr.addEventListener('load', () => {
if (bar) bar.style.display = 'none';
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error('Invalid JSON response')); }
} else {
let errMsg = `Upload failed (${xhr.status})`;
try { const d = JSON.parse(xhr.responseText); errMsg = d.error || errMsg; } catch {}
reject(new Error(errMsg));
}
});
xhr.addEventListener('error', () => {
if (bar) bar.style.display = 'none';
reject(new Error('Upload failed — check your connection'));
});
xhr.addEventListener('abort', () => {
if (bar) bar.style.display = 'none';
reject(new Error('Upload cancelled'));
});
xhr.send(formData);
});
},
async _uploadImage(file) {
if (!this.currentChannel) return;
// Capture the target channel NOW (before any await) so a mid-upload channel
@ -3198,23 +3285,12 @@ async _uploadImage(file) {
if (partner) {
// E2E path: encrypt file → upload as opaque blob → send encrypted text marker
try {
this._showToast('Encrypting & uploading image...', 'info');
const arrayBuffer = await file.arrayBuffer();
const encrypted = await this.e2e.encryptBytes(arrayBuffer, partner.userId, partner.publicKeyJwk);
const blob = new Blob([encrypted], { type: 'application/octet-stream' });
const formData = new FormData();
formData.append('file', blob, 'e2e-image.enc');
const res = await fetch('/api/upload-file', {
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');
}
const data = await res.json();
const data = await this._uploadWithProgress('/api/upload-file', formData);
const mime = file.type || 'image/png';
const marker = `e2e-img:${mime}:${data.url}`;
const encryptedText = await this.e2e.encrypt(marker, partner.userId, partner.publicKeyJwk);
@ -3235,18 +3311,7 @@ async _uploadImage(file) {
formData.append('image', file);
try {
this._showToast('Uploading image...', 'info');
const res = await fetch('/api/upload', {
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');
}
const data = await res.json();
const data = await this._uploadWithProgress('/api/upload', formData);
// Send the image URL as a message to the channel that was active at upload time
this.socket.emit('send-message', {
@ -3255,8 +3320,8 @@ async _uploadImage(file) {
isImage: true
});
this.notifications.play('sent');
} catch {
this._showToast('Upload failed — check your connection', 'error');
} catch (err) {
this._showToast(err.message || 'Upload failed', 'error');
}
},

View file

@ -594,6 +594,18 @@ _renderVoiceUsers(users) {
this._showVoiceUserMenu(btn || item, userId, username);
});
});
// Bind LIVE badges — clicking restores a hidden stream tile
el.querySelectorAll('.voice-stream-badge.live').forEach(badge => {
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
e.stopPropagation();
const userId = parseInt(badge.closest('.voice-user-item')?.dataset.userId);
if (isNaN(userId)) return;
const tile = document.querySelector(`#screen-tile-${userId}[data-hidden="true"]`);
if (tile) this._showStreamTile(`screen-tile-${userId}`, userId);
});
});
},
_showVoiceUserMenu(anchorEl, userId, username) {
@ -604,6 +616,10 @@ _showVoiceUserMenu(anchorEl, userId, username) {
const isDeafened = this.voice ? this.voice.isUserDeafened(userId) : false;
// Show voice kick for admins and mods with kick_user permission
const canKick = this._hasPerm('kick_user');
// Check if user is streaming and has a hidden tile we can restore
const streams = this._streamInfo || [];
const isStreaming = streams.some(s => s.sharerId === userId);
const hiddenTile = isStreaming ? document.querySelector(`#screen-tile-${userId}[data-hidden="true"]`) : null;
const menu = document.createElement('div');
menu.className = 'voice-user-menu';
menu.innerHTML = `
@ -614,6 +630,7 @@ _showVoiceUserMenu(anchorEl, userId, username) {
<span class="voice-user-vol-value">${savedVol}%</span>
</div>
<div class="voice-user-menu-actions">
${hiddenTile ? `<button class="voice-user-menu-action" data-action="watch-stream">🖥 Watch Stream</button>` : ''}
<button class="voice-user-menu-action" data-action="mute-user">${isMuted ? '🔊 Unmute' : '🔇 Mute'}</button>
<button class="voice-user-menu-action ${isDeafened ? 'active' : ''}" data-action="deafen-user">${isDeafened ? '🔊 Undeafen' : '🔇 Deafen'}</button>
${canKick ? `<button class="voice-user-menu-action danger" data-action="voice-kick" title="Remove from voice channel">🚪 Voice Kick</button>` : ''}
@ -651,7 +668,11 @@ _showVoiceUserMenu(anchorEl, userId, username) {
menu.querySelectorAll('.voice-user-menu-action').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (btn.dataset.action === 'mute-user') {
if (btn.dataset.action === 'watch-stream') {
// Restore the hidden stream tile
this._showStreamTile(`screen-tile-${userId}`, userId);
this._closeVoiceUserMenu();
} else if (btn.dataset.action === 'mute-user') {
// Mute: toggle their volume to 0 so YOU can't hear THEM
const newVol = parseInt(slider.value) === 0 ? 100 : 0;
slider.value = newVol;

View file

@ -795,27 +795,14 @@ _handleScreenStream(userId, stream, { force = false } = {}) {
});
tile.appendChild(minBtn);
// Close button — removes tile entirely and mutes its audio
// Close button — hides tile and mutes its audio (can be restored from hidden bar)
const closeBtn = document.createElement('button');
closeBtn.className = 'stream-close-btn';
closeBtn.title = 'Close (stop audio)';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Mute and clean up audio
if (userId) this.voice.setStreamVolume(userId, 0);
const audioEl = document.getElementById(`voice-audio-screen-${userId}`);
if (audioEl) { audioEl.volume = 0; try { audioEl.pause(); } catch {} }
// Notify server we stopped watching
if (this.voice && this.voice.inVoice && userId && userId !== this.user.id) {
this.socket.emit('stream-unwatch', { code: this.voice.currentChannel, sharerId: userId });
}
// Remove tile from DOM
const vid = tile.querySelector('video');
if (vid) vid.srcObject = null;
tile.remove();
this._updateHiddenStreamsBar();
this._updateScreenShareVisibility();
this._hideStreamTile(tile, userId, who, true);
});
tile.appendChild(closeBtn);
@ -1117,6 +1104,9 @@ _showStreamTile(tileId, userId) {
if (tile) {
tile.style.display = '';
delete tile.dataset.hidden;
// Re-play video (browsers may pause while display:none)
const vid = tile.querySelector('video');
if (vid && vid.srcObject) vid.play().catch(() => {});
// Restore audio if it was muted by close
if (tile.dataset.muted === 'true') {
delete tile.dataset.muted;
@ -1171,6 +1161,9 @@ _updateHiddenStreamsBar() {
t.style.display = '';
delete t.dataset.hidden;
const uid = t.id.replace('screen-tile-', '');
// Re-play video (browsers may pause while display:none)
const vid = t.querySelector('video');
if (vid && vid.srcObject) vid.play().catch(() => {});
// Restore audio if it was muted by close
if (t.dataset.muted === 'true') {
delete t.dataset.muted;

View file

@ -304,10 +304,25 @@ class VoiceManager {
};
if (savedInputId) audioConstraints.deviceId = { exact: savedInputId };
this.rawStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
video: false
});
try {
this.rawStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
video: false
});
} catch (deviceErr) {
if (savedInputId) {
// Saved device may be stale — retry with default mic
console.warn('Saved mic device failed, falling back to default:', deviceErr.message);
localStorage.removeItem('haven_input_device');
delete audioConstraints.deviceId;
this.rawStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
video: false
});
} else {
throw deviceErr;
}
}
// Opt out of Windows audio ducking (Desktop app only).
// Must be called after getUserMedia so our audio session exists.

View file

@ -416,7 +416,7 @@ function initDatabase() {
'kick_user', 'mute_user', 'delete_message', 'pin_message',
'set_channel_topic', 'manage_sub_channels', 'rename_channel',
'rename_sub_channel', 'delete_lower_messages', 'manage_webhooks',
'upload_files', 'use_voice', 'view_history',
'upload_files', 'use_voice', 'view_history', 'view_all_members',
'delete_own_messages', 'edit_own_messages'
];
serverModPerms.forEach(p => insertPerm.run(serverMod.lastInsertRowid, p));

View file

@ -2579,6 +2579,14 @@ function setupSocketHandlers(io, db) {
const newContent = sanitizeText(data.content.trim());
if (!newContent) return;
// Prevent turning a text message into an image/file by editing in an upload path
if (/^\/uploads\/[\w\-]+\.(jpg|jpeg|png|gif|webp)$/i.test(newContent)) {
const origMsg = db.prepare('SELECT original_name FROM messages WHERE id = ?').get(data.messageId);
if (!origMsg || !origMsg.original_name) {
return socket.emit('error-msg', 'Cannot change a text message into an image');
}
}
try {
db.prepare(
'UPDATE messages SET content = ?, edited_at = datetime(\'now\') WHERE id = ?'
@ -4687,30 +4695,76 @@ function setupSocketHandlers(io, db) {
return a.username.toLowerCase().localeCompare(b.username.toLowerCase());
});
// Send per-socket: invisible users appear offline to others, but normal to themselves
// Build "all server users" list for recipients with view_all_members perm.
// Only built on demand (lazy) to avoid the cost when nobody needs it.
let _allServerUsers = null;
function getAllServerUsers() {
if (_allServerUsers) return _allServerUsers;
const globalOnlineIds = new Set();
for (const [, s2] of io.of('/').sockets) {
if (s2.user) globalOnlineIds.add(s2.user.id);
}
const allRows = db.prepare(
`SELECT u.id, COALESCE(u.display_name, u.username) as username
FROM users u LEFT JOIN bans b ON u.id = b.user_id
WHERE b.id IS NULL
ORDER BY COALESCE(u.display_name, u.username)`
).all();
_allServerUsers = allRows.map(m => ({
id: m.id, username: m.username, online: globalOnlineIds.has(m.id),
highScore: scores[m.id] || 0,
status: statusMap[m.id]?.status || 'online',
statusText: statusMap[m.id]?.statusText || '',
avatar: statusMap[m.id]?.avatar || null,
avatarShape: statusMap[m.id]?.avatarShape || 'circle',
role: getUserHighestRole(m.id, channel ? channel.id : null)
}));
_allServerUsers.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.username.toLowerCase().localeCompare(b.username.toLowerCase());
});
return _allServerUsers;
}
// Check if per-socket emission is needed
const hasInvisible = users.some(u => u.status === 'invisible');
if (!hasInvisible) {
// Fast path: no invisible users, broadcast to everyone
let hasViewAll = false;
for (const [, s] of io.of('/').sockets) {
if (s.user && s.rooms && s.rooms.has(`channel:${code}`)) {
if (s.user.isAdmin || userHasPermission(s.user.id, 'view_all_members')) {
hasViewAll = true;
break;
}
}
}
if (!hasInvisible && !hasViewAll) {
// Fast path: no invisible users and no view_all_members, broadcast to everyone
io.to(`channel:${code}`).emit('online-users', {
channelCode: code,
users,
visibilityMode: mode
});
} else {
// Slow path: customize the list per recipient
// Per-socket path: customizes list for invisible users and view_all_members
for (const [, s] of io.of('/').sockets) {
if (!s.user || !s.rooms || !s.rooms.has(`channel:${code}`)) continue;
const viewerId = s.user.id;
const customUsers = users.map(u => {
const viewerHasViewAll = s.user.isAdmin || userHasPermission(viewerId, 'view_all_members');
let baseList = viewerHasViewAll ? getAllServerUsers() : users;
const customUsers = baseList.map(u => {
if (u.status === 'invisible' && u.id !== viewerId) {
return { ...u, online: false, status: 'offline' };
}
return u;
});
customUsers.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.username.toLowerCase().localeCompare(b.username.toLowerCase());
});
if (hasInvisible) {
customUsers.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.username.toLowerCase().localeCompare(b.username.toLowerCase());
});
}
s.emit('online-users', {
channelCode: code,
users: customUsers,
@ -4728,11 +4782,13 @@ function setupSocketHandlers(io, db) {
// All authenticated users can view the member list
const isAdmin = socket.user.isAdmin;
const canMod = isAdmin || userHasPermission(socket.user.id, 'kick_user') || userHasPermission(socket.user.id, 'ban_user');
const canSeeAll = canMod || userHasPermission(socket.user.id, 'view_all_members');
try {
// Admins/mods see all users; regular users only see people they share a channel with
// Users with view_all_members, admins, or mods see all users;
// regular users only see people they share a channel with
let users;
if (canMod) {
if (canSeeAll) {
users = db.prepare(`
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
@ -5676,14 +5732,23 @@ function setupSocketHandlers(io, db) {
const code = generateChannelCode();
const isPrivate = data.isPrivate ? 1 : 0;
// Optional temporary sub-channel: duration in hours (1720 = 30 days max)
let expiresAt = null;
if (data.temporary && data.duration) {
const hours = Math.max(1, Math.min(720, parseInt(data.duration, 10)));
if (!isNaN(hours)) {
expiresAt = new Date(Date.now() + hours * 3600000).toISOString();
}
}
// Get max position for ordering
const maxPos = db.prepare('SELECT MAX(position) as mp FROM channels WHERE parent_channel_id = ?').get(parentChannel.id);
const position = (maxPos && maxPos.mp != null) ? maxPos.mp + 1 : 0;
try {
const result = db.prepare(
'INSERT INTO channels (name, code, created_by, parent_channel_id, position, is_private) VALUES (?, ?, ?, ?, ?, ?)'
).run(name, code, socket.user.id, parentChannel.id, position, isPrivate);
'INSERT INTO channels (name, code, created_by, parent_channel_id, position, is_private, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(name, code, socket.user.id, parentChannel.id, position, isPrivate, expiresAt);
// Auto-join all members of the parent channel (even for private — creator controls who's in)
const parentMembers = db.prepare('SELECT user_id FROM channel_members WHERE channel_id = ?').all(parentChannel.id);
@ -5891,6 +5956,40 @@ function setupSocketHandlers(io, db) {
}
});
// ── Set channel self-destruct timer ─────────────────────────────
socket.on('set-channel-expiry', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'create_channel')) {
return socket.emit('error-msg', 'You don\'t have permission to set self-destruct timers');
}
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(code);
if (!channel) return socket.emit('error-msg', 'Channel not found');
let expiresAt = null;
if (data.hours && data.hours > 0) {
const hours = Math.max(1, Math.min(720, parseInt(data.hours, 10)));
if (isNaN(hours)) return socket.emit('error-msg', 'Invalid duration');
expiresAt = new Date(Date.now() + hours * 3600000).toISOString();
}
try {
db.prepare('UPDATE channels SET expires_at = ? WHERE id = ?').run(expiresAt, channel.id);
broadcastChannelLists();
if (expiresAt) {
const hours = Math.round((new Date(expiresAt) - Date.now()) / 3600000);
socket.emit('toast', { message: `⏱️ Channel will self-destruct in ${hours}h`, type: 'success' });
} else {
socket.emit('toast', { message: '⏱️ Self-destruct timer removed', type: 'success' });
}
} catch (err) {
console.error('Set channel expiry error:', err);
socket.emit('error-msg', 'Failed to set self-destruct timer');
}
});
// ── Set channel notification type (default / announcement) ────
socket.on('set-notification-type', (data) => {
if (!data || typeof data !== 'object') return;

View file

@ -894,13 +894,13 @@
<span class="discord-feat">🖥️ Windows &amp; Linux</span>
</div>
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/Haven-Setup-1.1.4.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/Haven-Setup-1.1.5.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Windows Installer
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/Haven-1.1.4.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/Haven-1.1.5.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux AppImage
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.4/haven-desktop_1.1.4_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.1.5/haven-desktop_1.1.5_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1365,12 +1365,12 @@
</div>
<div class="download-card fade-in">
<h2>⬡ Haven Server &mdash; v2.7.7</h2>
<h2>⬡ Haven Server &mdash; v2.7.8</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip" class="btn btn-primary download-main">
<span class="icon"></span> Download v2.7.7 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.8.zip" class="btn btn-primary download-main">
<span class="icon"></span> Download v2.7.8 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1387,7 +1387,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.7.7</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v2.7.8</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.8.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.7.7</span> &mdash; Temporary channels, members list privacy, URL @ fix</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip">Download &rarr;</a>
</div>
<div class="version-item">