mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
v2.7.8 — upload progress bar, view all members perm, notification nav fix, stream reopen, Docker update fix
This commit is contained in:
parent
b44cca07a0
commit
fb58245f50
17 changed files with 398 additions and 98 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
19
GUIDE.md
19
GUIDE.md
|
|
@ -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.
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -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):
|
||||
|
||||
[](https://zeabur.com/templates?repoURL=https://github.com/ancsemi/Haven)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -894,13 +894,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & 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 — v2.7.7</h2>
|
||||
<h2>⬡ Haven Server — v2.7.8</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~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">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.7.7</span> — Temporary channels, members list privacy, URL @ fix</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -443,6 +443,10 @@
|
|||
<button class="reply-bar-close" id="reply-close-btn" title="Cancel reply">×</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '1–720h (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');
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 (1–720 = 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;
|
||||
|
|
|
|||
|
|
@ -894,13 +894,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & 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 — v2.7.7</h2>
|
||||
<h2>⬡ Haven Server — v2.7.8</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~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">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.7.7</span> — Temporary channels, members list privacy, URL @ fix</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.7.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
Loading…
Reference in a new issue