Compare commits

...

39 commits
v3.2.0 ... main

Author SHA1 Message Date
ancsemi
1f0d8f2006 Add channel/message links + configurable admin backup checkboxes (#5266)
Channel links: right-click any channel or DM to copy a deep link. Message toolbar gets a copy-link button that jumps to the message after navigating. Links survive login via sessionStorage handoff (same pattern as invite codes).

Configurable backup: replaces the structure/full split with a checkbox group for channels/roles, users (sanitized), server settings, messages, and uploaded files. Server endpoint accepts ?include=channels,users,settings,messages,files; old ?mode=structure|full still works.
2026-04-21 09:29:28 -04:00
ancsemi
9f2f4dfd74 SSO consent: tighten validate timeout (4s) and watchdog (5s), fall back to cached profile if validate is slow 2026-04-21 09:29:04 -04:00
ancsemi
feb357bd9f Server list: preserve subpath URLs when normalizing (e.g. https://host/community) 2026-04-21 09:29:00 -04:00
ancsemi
bb02f49526 fix(servers): cache-bust server icon URLs to bypass stale pre-CORS cache (#5240)
Browsers may have cached an icon response without CORS headers from a pre-fix load. Even after the server now sets Cross-Origin-Resource-Policy and Vary: Origin correctly, that stale cache entry can still be served for new crossorigin requests, producing 'No Access-Control-Allow-Origin' errors. Appending a stable ?_cb=cors2 marker to icon URLs forces a fresh fetch the first time and gives the new response a clean cache key.
2026-04-21 07:53:33 -04:00
ancsemi
0cacae73b3 feat(admin): remote server backup and restore (#5266)
Adds admin-only endpoints to download a backup of the server (structure-only or full DB+uploads zip) and to upload a full backup to restore from. Useful for hosts without shell access to the machine. Adds a Backup section to the Admin settings tab with download buttons and a restore upload field. Restore stages the data and exits the process so a supervisor (Docker, systemd, installer service) restarts Haven; the previous DB and uploads are preserved as .pre-restore copies for one cycle.
2026-04-21 07:52:23 -04:00
ancsemi
dd8258d46b Set Cross-Origin-Resource-Policy and Vary on image/health responses for cross-server icon loading 2026-04-20 21:15:17 -04:00
ancsemi
83fbfb5fd4 feat: add threads, toolbar customization, and SSO/auth flow improvements 2026-04-20 18:28:50 -04:00
ancsemi
47454daead Revert "Update community server link to vanity invite URL, remove manual channel code box"
This reverts commit e59a414bb6.
2026-04-20 08:48:11 -04:00
ancsemi
e59a414bb6 Update community server link to vanity invite URL, remove manual channel code box 2026-04-20 07:54:38 -04:00
ancsemi
dd2df1727a fix: auto-inject version into cache-busting query strings
The query strings in app.html were stuck at v2.x, so Electron and
aggressive caches served stale JS/CSS even after a server update.

The /app route now reads app.html and replaces all ?v= strings with
the current package.json version, ensuring clients always fetch fresh
assets after an update.
2026-04-19 19:15:29 -04:00
ancsemi
e6a0caa0e7 docs: update download pages to v3.4.0 2026-04-19 16:57:53 -04:00
ancsemi
8715392e50 docs: update CHANGELOG for v3.4.0 2026-04-19 16:57:50 -04:00
ancsemi
0c2b4a8be4 chore: bump version to 3.4.0 2026-04-19 16:57:47 -04:00
ancsemi
17519d5a09 Security: validate reply-to channel boundary and add WebRTC payload size limits
- Reply-to references are now checked to ensure the target message
  belongs to the same channel, preventing cross-channel data leaks.
- History reply context queries also scoped to current channel.
- WebRTC offer/answer capped at 16 KB, ICE candidates at 2 KB to
  prevent oversized relay payloads.
2026-04-19 16:49:33 -04:00
ancsemi
b56dff0535 Decouple event sounds from master notifications toggle (#5264)
Mentions, replies, DMs, sent, join, and leave sounds now play
independently of the Notifications checkbox. Only regular message
pings/announcements are gated by the master toggle. Voice UI cues
(mute, deafen, etc.) also always play.
2026-04-19 16:49:27 -04:00
ancsemi
76336b74fd Add crossorigin attribute to server icon images for cross-origin CORS loading (#5240) 2026-04-19 16:49:21 -04:00
ancsemi
f05a62d14e fix: use server fingerprint to reliably hide self from sidebar
The origin-based filter missed cases where the same server was stored
under a different URL (e.g. public domain vs localhost).

- Generate a unique server_fingerprint UUID in server_settings on first run
- Include fingerprint in /api/health response
- Client fetches own fingerprint on init, stores it in ServerManager
- Health checks store each server's fingerprint in statusCache
- Sidebar, manage-servers, and mobile views filter by fingerprint match
- Re-render triggered once selfFingerprint resolves to avoid flash
2026-04-19 15:46:37 -04:00
ancsemi
7f866838ce fix: filter current server from sidebar and respect removals in Desktop bridge
- _renderServerBar(), _renderManageServersList(), _renderMobileSidebarServers()
  now filter out servers matching window.location.origin
- Desktop bridge in _setupServerBar() checks removed set before merging
  Electron server history, preventing deleted servers from reappearing
2026-04-19 14:22:35 -04:00
ancsemi
032b4aee6a fix: normalize server URLs to origin and persist removals across sync
- add _normalizeUrl() helper that strips paths/trailing slashes/default ports
- add() now normalizes URLs to prevent duplicates from URL variants
- add() clears removed set when user explicitly re-adds a server
- remove() now calls markRemoved() so deleted servers stay deleted
- markRemoved() stores both exact URL and normalized origin
- syncWithServer() checks both URL and normalized origin against removed set
2026-04-19 14:22:28 -04:00
ancsemi
28108dae56 Add quote button to message toolbar
Clicking the speech-bubble icon inserts the message as a blockquote
(> prefix) with @author into the input. Translations added to all
six locale files.
2026-04-19 13:28:47 -04:00
ancsemi
923576a015 Up arrow edits last message + toggleable setting
Press Up arrow on an empty input to edit your most recent message.
Toggle available in Settings > Chat (on by default).
2026-04-19 13:28:41 -04:00
ancsemi
b964b1c23c Skip desktop app promo on mobile/tablet devices
Users on Android/iPhone/iPad no longer see the 'Desktop app available'
banner and popup. Electron detection was already in place.
2026-04-19 13:28:34 -04:00
ancsemi
c48534b6ae Ship donor-order.json with all installs (remove from gitignore)
The sort toggle (Early Believers / Biggest Thanks!) was invisible on
other servers because donor-order.json was gitignored. Now all Haven
installs ship with the file. No donation amounts are exposed, only
the ordering of names.
2026-04-19 13:28:28 -04:00
ancsemi
69b03592bb fix: 'pull icon from server' fails cross-origin
Add CORS headers to /uploads for image files so cross-origin icon
fetching works. Also update icon URL on every health check instead of
only when icon was previously unset, allowing icon changes to propagate.
2026-04-19 03:20:22 -04:00
ancsemi
b02b9a9ff7 fix: server list sync broken on page refresh / auto-login
Persist the E2E wrapping key to localStorage as haven_sync_key so
server list sync works on auto-login (no password entry). Previously
the key was only in sessionStorage, lost on tab close/refresh.
Clear haven_sync_key on logout and account deletion.
2026-04-19 03:20:12 -04:00
ancsemi
316494f877 fix: SSO consent page stuck at 'Checking login status...'
Wrap localStorage access in try/catch to prevent silent SecurityError
crashes in popups/Electron. Add /api/auth/validate endpoint and use
server-side token verification as primary check with localStorage fallback.
2026-04-19 03:20:05 -04:00
ancsemi
fe5a17d178 feat: SSO 'Link a Server' shows recent servers dropdown
Populate datalist from localStorage haven_servers so users see their
recent servers when entering the home server URL for SSO.
2026-04-19 03:19:59 -04:00
ancsemi
93479775ae feat: bot API - delete messages and play soundboard sounds
Add DELETE /api/webhooks/:token/messages/:messageId for bots to delete messages.
Add POST /api/webhooks/:token/sounds for bots to trigger soundboard sounds.
Add client-side play-sound socket handler for bot-triggered sounds.
2026-04-19 03:19:53 -04:00
ancsemi
00c6689c89 Add Bot Developer Guide section to GUIDE.md, update FAQ link in README 2026-04-19 03:19:47 -04:00
ancsemi
5a02e41e49 fix: prevent stale socket from evicting active user in voice channel
When a user joins voice from a second client (B-2), the server kicks
the old socket (B-1) via handleVoiceLeave, then adds B-2's entry.
B-1's client then receives voice-kicked, calls leave(), and emits
voice-leave — which triggered handleVoiceLeave again on B-1's socket.
Because the check was only voiceRoom.has(userId), it matched B-2's
entry and deleted it, making everyone think B left.

Fix: check entry.socketId === socket.id before proceeding with cleanup.
If the socket is stale (superseded by another client), just remove it
from the socket.io room and return without touching the voiceUsers map.
2026-04-19 02:17:39 -04:00
ancsemi
a5df2d9b13 chore: bump version to 3.3.0, update changelog and download pages 2026-04-18 21:12:53 -04:00
ancsemi
9f5da48976 Improve server-list sync: periodic resync + tab-focus trigger
- Re-sync encrypted server list every 5 minutes (if wrapping key available)
- Trigger sync when tab becomes visible (catches changes from other devices)
- Both fire only when e2e wrapping key is in memory (password-based login)
2026-04-18 20:27:46 -04:00
ancsemi
564bae8750 Fix iOS Safari safe-area overlap on mobile
- JS probes actual env(safe-area-inset-*) values via hidden element
- Sets --safe-top / --safe-bottom CSS custom properties with device-
  specific minimum floors (47px for notched iPhones, 20px for older)
- Targeted .is-ios / .is-android body classes for platform-specific CSS
- Hardened channel-header and message-input-area padding via !important
  to override theme specificity issues
- Handles both Safari browser and PWA standalone modes
2026-04-18 20:27:39 -04:00
ancsemi
e4db71dd09 Add per-event volume sliders for join/leave sounds
- Join Volume and Leave Volume sliders in Sounds settings
- Each uses independent localStorage key (haven_notif_join_volume, haven_notif_leave_volume)
- Follows same pattern as @Mention and Reply volume controls
- Addresses ancsemi/Haven-Desktop#23
2026-04-18 20:27:35 -04:00
Reverb
0d1858f747
fix: disable CSP upgrade-insecure-requests when FORCE_HTTP=true (#5258)
Helmet 8.x automatically appends 'upgrade-insecure-requests' to every
Content-Security-Policy response, even when custom directives are provided.

When a Haven server runs with FORCE_HTTP=true (plain HTTP, no TLS),
this directive causes browsers and Electron clients to upgrade all
http:// subresource requests (CSS, JS, fonts) to https://. Since the
server has no TLS, those requests fail silently, resulting in a broken
UI with no styling or JavaScript.

Fix: spread { upgradeInsecureRequests: null } into CSP directives when
FORCE_HTTP=true, which tells helmet to omit the directive entirely.

The HSTS header is already correctly disabled for FORCE_HTTP=true (line
121), but the CSP directive was missed.

Co-authored-by: reverb256 <reverb256@users.noreply.github.com>
2026-04-17 01:11:25 -04:00
ancsemi
f2827690a0 feat: last read message indicator (#5259)
- Server: include lastReadMessageId in message-history response by querying
  read_positions for the user's last read position in the channel
- Client: render a 'NEW MESSAGES' divider between the last read message and
  the first unread message on initial channel load
- Auto-scroll to the divider so users see where they left off
- Skip divider when user is fully caught up or all messages are unread
- CSS: styled divider with accent-colored line and label (Teams-style)
2026-04-16 22:53:26 -04:00
ancsemi
8befde0b15 fix: properly clean up duplicate voice joins from multiple clients (#5247)
- Use handleVoiceLeave() on the old socket instead of manual map deletion,
  ensuring peer connections, screen shares, and webcams are fully cleaned up
- Emit 'voice-kicked' to the old client so it disconnects its local voice UI
- Add client-side voice-kicked handler that calls leave() and shows a toast
- Re-create voiceUsers map entry if handleVoiceLeave cleaned it up
2026-04-16 22:53:26 -04:00
ancsemi
f5e94877f0 fix: case-insensitive channel tag grouping (#5260)
- Server: when setting a category, reuse existing casing of matching tag
  (COLLATE NOCASE) so 'Gaming' and 'gaming' always share the same group
- Client sidebar: group channels by tag case-insensitively
- Client organize modal: deduplicate and filter tags case-insensitively
- Sub-channel tag headers: compare case-insensitively
2026-04-16 22:53:25 -04:00
Amnibro
2d82cc697c
fix(e2e): distinguish 'no backup' from 'unreachable' to prevent clobber (#5261)
`_fetchBackup`'s 5s timeout returned null for both server-confirmed empty
and request-timed-out. Flaky mobile reconnects during init could therefore
trigger step 1b to re-upload a stale local key, or step 3 to mint a fresh
keypair — either path overwrites a good server backup and splits the
account into two key universes. The tell is messages decrypting for one
exchange then going dark.

- `_fetchBackupWithState` returns explicit {status: 'present'|'none'|'unknown'},
  15s timeout, 1 retry. Legacy `_fetchBackup` shim preserved for compat.
- init() only re-uploads (step 1b) or generates (step 3) on affirmative
  'none'. On 'unknown' it bails out with ghostState=true so UI can prompt.
- Divergence check: when server has a backup but pub key differs from
  local, flag divergent=true for UI.
- 5-min IndexedDB-backed regenerate cooldown kills flap-regenerate storms.
- Server get-encrypted-key returns `state: 'present'|'empty'|'error'` plus
  the public key JWK so clients can detect divergence without a round-trip.
  Additive — legacy clients ignore the new fields.

Signed-off-by: Amnibro <99765883+Amnibro@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 22:37:44 -04:00
39 changed files with 4020 additions and 277 deletions

BIN
.gitignore vendored

Binary file not shown.

View file

@ -11,6 +11,66 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
---
## [3.5.0] — 2026-04-20
### Added
- **Threaded replies panel** — message threads now open in a dedicated right-side panel with parent context, inline reply flow, and live updates.
- **Thread previews in channel chat** — parent messages now show thread activity summaries with reply count, recent participants, and last activity timestamp.
- **Thread panel PiP mode and resize handle** — thread conversations can be popped out into a floating panel and resized for multitasking.
- **Toolbar icon and layout customization** — settings now include monochrome vs emoji toolbar styles, visible action slot count, and per-action order controls.
### Fixed
- **SSO approval reliability and feedback** — improved SSO consent/auth flow with clearer status messages, timeout handling, profile return via `postMessage`, and stronger fallback behavior.
- **Vanity invite continuity through auth redirects**`invite` query params now persist through login/register flows and redirect correctly into `/app`.
- **Thread-aware message queries** — primary channel history now excludes thread replies to prevent duplicate rendering and keep main timelines clean.
- **Cache-busting version query injection** — static asset version query strings are now auto-injected more reliably to reduce stale client bundles after updates.
### Changed
- **SSO response metadata** — SSO auth responses now include display name data and stricter CORS/origin handling for cross-origin auth handoff.
- **Database schema for threads** — added `messages.thread_id` migration and index to support efficient threaded message fetches.
---
## [3.4.0] — 2026-04-19
### Added
- **Quote button** — a quote button in the message toolbar inserts a formatted quote of the selected message into the input box.
- **Up-arrow to edit last message** — pressing up in an empty message input opens the last message you sent for editing. Toggleable in Settings.
- **Bot API: delete messages & play soundboard sounds** — bots can now delete messages and trigger soundboard sound playback via the API.
- **SSO recent-servers dropdown** — the SSO "Link a Server" page now shows a dropdown of recently visited servers for quick selection.
### Fixed
- **Event sounds decoupled from notifications toggle** — join/leave sounds now play regardless of whether the master notifications toggle is off. (#5264)
- **Server icon cross-origin loading** — server icons fetched from external origins now include the correct `crossorigin` attribute, preventing CORS errors. (#5240)
- **Server list hides current server reliably** — the server list sidebar now uses the server fingerprint to identify and hide the host server, fixing cases where it appeared in its own list.
- **Server list removals persist** — manually removed servers are now normalized by origin and persist across syncs; the Desktop bridge also respects removals.
- **Server list sync on page refresh / auto-login** — the encrypted server list now syncs correctly when the page reloads or the user auto-logs in.
- **SSO consent page "Checking login status..."** — the SSO consent page no longer gets stuck in a loading state after a session is already established.
- **Desktop app promo skipped on mobile/tablet** — the desktop app promotional modal no longer appears on mobile or tablet devices.
- **Stale socket evicting active voice users** — a stale socket reconnect no longer incorrectly removes an active user from a voice channel.
### Security
- **Reply-to channel boundary validation** — the server now validates that a reply target belongs to the same channel, preventing cross-channel reply injection.
- **WebRTC payload size limits** — enforced maximum payload sizes on WebRTC data channel messages to limit potential abuse.
---
## [3.3.0] — 2026-04-18
### Added
- **Last read message indicator** — a subtle divider marks where you left off when you return to a channel. (#5259)
- **Per-event volume sliders** — separate volume controls for join and leave notification sounds in User Settings.
- **Server-list sync improvements** — the encrypted server list now resyncs periodically and on tab focus, so your server list stays current across devices without a full reload.
### Fixed
- **iOS Safari safe-area overlap** — additional safe-area inset fixes on mobile Safari preventing content from being clipped by notches and home indicator.
- **CSP upgrade-insecure-requests with FORCE_HTTP** — the Content Security Policy no longer forces HTTPS upgrades when `FORCE_HTTP=true` is set, which was breaking HTTP-only installs. (#5258)
- **Duplicate voice joins** — properly cleans up stale state from a race condition where rapidly clicking join could register a client twice in the same voice channel. (#5247)
- **Case-insensitive channel tag grouping** — channel tags are now matched case-insensitively, so `[General]` and `[general]` are treated as the same group. (#5260)
- **E2E backup clobber prevention** — the encryption backup flow now correctly distinguishes between "no backup exists" and "backup server unreachable," preventing a reachability failure from overwriting a valid backup. (#5261)
---
## [3.2.0] — 2026-04-16
### Added

View file

@ -666,6 +666,93 @@ Click the **🔐** button in the DM header to view your **safety number** — a
---
## 🤖 Bot & Webhook Developer Guide
Haven has a built-in bot API powered by webhooks. Bots can send messages, delete messages, play soundboard sounds, and register custom slash commands.
### Creating a Bot
1. Go to **Settings → Server Admin Settings → Bots** (or open a channel's settings and look for the webhook/bot option)
2. Create a new webhook — give it a name, optionally set an avatar URL and a callback URL
3. Copy the **Webhook Token** (64-character hex string) — this is your bot's API key
### Sending Messages
```
POST https://your-server.com/api/webhooks/<token>
Content-Type: application/json
{
"content": "Hello from my bot!",
"username": "MyBot",
"avatar_url": "https://example.com/avatar.png"
}
```
- `content` (required) — message text, max 4000 characters
- `username` (optional) — override the bot's display name for this message
- `avatar_url` (optional) — override the bot's avatar for this message
Response: `{ "success": true, "message_id": 123 }`
### Deleting Messages
```
DELETE https://your-server.com/api/webhooks/<token>/messages/<message_id>
```
Bots can delete any message in their assigned channel. Returns `{ "success": true }`.
### Playing Soundboard Sounds
```
POST https://your-server.com/api/webhooks/<token>/sounds
Content-Type: application/json
{
"sound": "AOL - You've Got Mail"
}
```
Plays the named sound for all users currently viewing the bot's channel. Use `GET /api/sounds` (with a Bearer token) to list available sound names.
### Registering Slash Commands
Bots with a `callback_url` can register custom slash commands that users can invoke from chat:
**Register:**
```
POST https://your-server.com/api/webhooks/<token>/commands
Content-Type: application/json
{
"command": "leaderboard",
"description": "Show the current leaderboard"
}
```
**List:**
```
GET https://your-server.com/api/webhooks/<token>/commands
```
**Unregister:**
```
DELETE https://your-server.com/api/webhooks/<token>/commands/leaderboard
```
When a user types `/leaderboard`, Haven sends a POST to your bot's callback URL with the command details, signed with HMAC so you can verify authenticity.
### Rate Limits
All webhook endpoints are rate-limited to **30 requests per minute** per IP.
### Callback Payloads
If your webhook has a `callback_url` and `callback_secret` configured, Haven will POST command invocations to your URL. The payload includes an HMAC signature in the `X-Haven-Signature` header that you should verify using your callback secret.
---
## 🆘 Troubleshooting
**"SSL_ERROR_RX_RECORD_TOO_LONG" or "ERR_SSL_PROTOCOL_ERROR" in browser**

View file

@ -516,7 +516,7 @@ Yes. You can join someone else's Haven server if they share an invite link with
Haven supports optional E2EE for direct messages (ECDH P-256 + AES-256-GCM). Channel messages are stored on your server, so your data security depends on your hosting setup.
**Can I create bots for Haven?**
Yes. Haven supports webhooks with a REST API — bots can send messages, register custom slash commands, and receive message callbacks with HMAC-signed payloads. Set up webhooks in your server's admin settings.
Yes. Haven supports webhooks with a REST API — bots can send messages, delete messages, play soundboard sounds, register custom slash commands, and receive message callbacks with HMAC-signed payloads. Set up webhooks in **Settings → Server Admin Settings → Bots**. See the [Bot Developer Guide](GUIDE.md#-bot--webhook-developer-guide) for full API docs.
**Does Haven have moderation tools?**
Yes — role-based permissions, kick/ban/mute, slow mode, read-only announcement channels, IP banning, and a full moderation REST API for bot-driven moderation.

View file

@ -953,13 +953,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.2.0/Haven-Setup-1.2.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-Setup-1.3.0.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.2.0/Haven-1.2.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-1.3.0.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.2.0/haven-desktop_1.2.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/haven-desktop_1.3.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v3.2.0</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</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/v3.2.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.2.0 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,19 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v3.2.0</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.3.0</span> &mdash; Last read indicator, per-event volume sliders, server list sync improvements</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.3.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.2.0</span> &mdash; Mark as Read, pinned message jump, iOS Safari fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip">Download &rarr;</a>
</div>
<div class="version-item">

33
donor-order.json Normal file
View file

@ -0,0 +1,33 @@
{
"sponsors": [
"Amnibro",
"MutantRabbbit767",
"c0urier",
"BillyAlt",
"infracritical",
"nexitem"
],
"donors": [
"Andrew Schott",
"birdycrazy",
"Taylan",
"(,,•ᴗ•,,)",
"Morgan",
"deNully",
"HoppyGamers",
"khyrna",
"wreckedcarzz",
"c0urier",
"Ezmana",
"john doe",
"JollyOrc",
"lataxd9",
"ohmygdala",
"Orange Lantern",
"MutantRabbbit767",
"CloneBtw",
"haruna",
"ArtyDaSmarty",
"6yBbBc"
]
}

View file

@ -1,6 +1,6 @@
{
"name": "haven",
"version": "3.2.0",
"version": "3.5.0",
"description": "Haven — self-hosted private chat for your server, your rules",
"license": "AGPL-3.0",
"main": "server.js",

View file

@ -10,8 +10,8 @@
<link rel="manifest" href="/manifest.webmanifest">
<title>Haven</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cG9seWdvbiBwb2ludHM9IjUwLDMgOTMsMjggOTMsNzIgNTAsOTcgNyw3MiA3LDI4IiBmaWxsPSJub25lIiBzdHJva2U9IiM2YjRmZGIiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjx0ZXh0IHg9IjUwIiB5PSI2MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsLEhlbHZldGljYSxzYW5zLXNlcmlmIiBmb250LXdlaWdodD0iYm9sZCIgZm9udC1zaXplPSIzOCIgZmlsbD0iIzZiNGZkYiIgb3BhY2l0eT0iMC45NSI+SDwvdGV4dD48L3N2Zz4=">
<link rel="stylesheet" href="/css/style.css?v=2.7.11">
<script src="/js/theme-init.js"></script>
<link rel="stylesheet" href="/css/style.css?v=3.4.0">
<script src="/js/theme-init.js?v=3.4.0"></script>
</head>
<body>
@ -32,6 +32,9 @@
<button class="server-icon manage-servers" id="manage-servers-btn" data-i18n-title="app.sidebar.manage_servers" title="Manage Servers">
<span class="server-icon-text"></span>
</button>
<button class="server-icon sync-servers" id="sync-servers-btn" title="Sync Server List">
<span class="server-icon-text"></span>
</button>
</nav>
<!-- ─── Left Sidebar ────────────────────────────── -->
@ -708,6 +711,37 @@
</div>
</div>
<!-- Thread Panel (slides in from right) -->
<div id="thread-panel" class="thread-panel" style="display:none">
<div class="thread-panel-resizer" id="thread-panel-resizer" aria-hidden="true"></div>
<div class="thread-panel-header">
<div class="thread-panel-header-top">
<span class="thread-panel-icon">🧵</span>
<span id="thread-panel-title" class="thread-panel-title">Thread</span>
<button id="thread-panel-pip" class="icon-btn small" title="Pop out thread (PiP)" aria-pressed="false"></button>
<button id="thread-panel-close" class="icon-btn small">&times;</button>
</div>
<div class="thread-parent-meta" id="thread-parent-meta">
<div class="thread-parent-avatar-wrap" id="thread-parent-avatar-wrap"></div>
<span class="thread-parent-name" id="thread-parent-name">Thread starter</span>
</div>
<div class="thread-parent-preview" id="thread-parent-preview"></div>
</div>
<div class="thread-messages" id="thread-messages"></div>
<div class="thread-reply-bar" id="thread-reply-bar" style="display:none">
<span id="thread-reply-preview-text"></span>
<button id="thread-reply-close-btn" class="icon-btn small" aria-label="Cancel thread reply">&times;</button>
</div>
<div class="thread-input-area">
<textarea id="thread-input" class="thread-input" placeholder="Reply in thread..." rows="1"></textarea>
<button id="thread-send-btn" class="thread-send-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/>
</svg>
</button>
</div>
</div>
<!-- First-Time Setup Wizard (Admin only) -->
<div class="modal-overlay" id="setup-wizard-modal" style="display:none">
<div class="modal modal-wizard">
@ -863,7 +897,8 @@
</div>
<div class="form-group">
<label data-i18n="modals.add_server.address_label">Server Address</label>
<input type="text" id="server-url-input" placeholder="e.g. https://192.168.1.5:3000">
<input type="text" id="server-url-input" placeholder="e.g. https://192.168.1.5:3000" list="known-servers-datalist">
<datalist id="known-servers-datalist"></datalist>
</div>
<div class="form-group">
<label data-i18n="modals.add_server.icon_label">Icon URL</label> <span class="muted-text" data-i18n="modals.add_server.icon_optional">(optional)</span>
@ -993,6 +1028,7 @@
<div class="settings-nav-item settings-nav-admin" data-target="section-whitelist" style="display:none">🛡️ <span data-i18n="settings.nav.whitelist">Whitelist</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-invite" style="display:none">🌐 <span data-i18n="settings.nav.invite">Invite Code</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-cleanup" style="display:none">🗑️ <span data-i18n="settings.nav.cleanup">Cleanup</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-backup" style="display:none">💾 <span data-i18n="settings.nav.backup">Backup</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-uploads" style="display:none">📁 <span data-i18n="settings.nav.limits">Limits</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-sounds-admin" style="display:none">🔊 <span data-i18n="settings.nav.admin_sounds">Sounds</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-emojis" style="display:none">😎 <span data-i18n="settings.nav.emojis">Emojis</span></div>
@ -1102,6 +1138,41 @@
<small class="settings-hint" style="margin-top:4px;display:block">How role colors are shown next to usernames in chat and the member list.</small>
</div>
<!-- Toolbar Icon Style -->
<div class="settings-section" id="section-toolbar-icons" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">🎛️ Toolbar Icons</h5>
<div class="density-picker" id="toolbar-icon-picker">
<button type="button" class="density-btn active" data-toolbaricons="mono" title="Use sleek monochrome icons in message toolbars">
<span class="density-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle>
<path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-linecap="round"></path>
<circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle>
<circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle>
</svg>
</span>
<span class="density-label">Monochrome</span>
</button>
<button type="button" class="density-btn" data-toolbaricons="emoji" title="Use colorful emoji icons in message toolbars">
<span class="density-icon">😀</span>
<span class="density-label">Colorful Emoji</span>
</button>
</div>
<label class="toolbar-slots-row" for="toolbar-visible-slots">
<span>Visible toolbar slots before overflow</span>
<input type="range" id="toolbar-visible-slots" min="1" max="7" step="1" value="3">
<span id="toolbar-visible-slots-value" class="toolbar-slots-value">3</span>
</label>
<div class="toolbar-order-wrap">
<div class="toolbar-order-head">
<span>Toolbar slot order</span>
<button type="button" class="btn-sm" id="toolbar-order-reset-btn">Reset</button>
</div>
<div id="toolbar-order-list" class="toolbar-order-list"></div>
</div>
<small class="settings-hint" style="margin-top:4px;display:block">Applies to message and thread hover action bars.</small>
</div>
<!-- Image Display Mode -->
<div class="settings-section" id="section-density" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">🖼️ <span data-i18n="settings.image_display.title">Image Display</span></h5>
@ -1142,6 +1213,16 @@
<small class="settings-hint">How the banner interacts with the channel header area. These settings are per-device.</small>
</div>
<!-- Chat Behavior -->
<div class="settings-section" id="section-chat-behavior" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">💬 Chat</h5>
<label class="toggle-row">
<span>Up Arrow Edits Last Message</span>
<input type="checkbox" id="up-arrow-edit" checked>
</label>
<small class="settings-hint" style="display:block;margin-bottom:6px">Press the Up arrow key on an empty input to quickly edit your last message.</small>
</div>
<!-- Status Bar -->
<div class="settings-section" id="section-statusbar" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">📊 Status Bar</h5>
@ -1253,6 +1334,10 @@
</select>
</label>
<div class="notif-divider"></div>
<label class="volume-row">
<span data-i18n="settings.sounds_section.join_vol">Join Vol</span>
<input type="range" id="notif-join-volume" min="0" max="100" value="80" class="slider-sm">
</label>
<label class="select-row">
<span data-i18n="settings.sounds_section.user_joined">User Joined</span>
<select id="notif-join-sound">
@ -1264,6 +1349,11 @@
<option value="none">None</option>
</select>
</label>
<div class="notif-divider"></div>
<label class="volume-row">
<span data-i18n="settings.sounds_section.leave_vol">Leave Vol</span>
<input type="range" id="notif-leave-volume" min="0" max="100" value="80" class="slider-sm">
</label>
<label class="select-row">
<span data-i18n="settings.sounds_section.user_left">User Left</span>
<select id="notif-leave-sound">
@ -1673,6 +1763,34 @@
<small class="settings-hint" data-i18n="settings.admin.cleanup_size_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;">🧹 <span data-i18n="settings.admin.cleanup_run_btn">Run Cleanup Now</span></button>
</div>
<div class="admin-settings" id="section-backup" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">💾 <span data-i18n="settings.admin.backup_title">Server Backup</span></h5>
<small class="settings-hint" data-i18n="settings.admin.backup_hint">Download a backup of this server, or restore from a previous backup. Useful when you don't have shell access to the host machine.</small>
<div class="settings-group" style="margin-top:10px">
<small class="settings-group-label" data-i18n="settings.admin.backup_download_label">Download Backup</small>
<div class="backup-checkboxes" style="display:flex;flex-direction:column;gap:4px;margin-top:6px">
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="channels" checked> <span data-i18n="settings.admin.backup_inc_channels">Channels &amp; roles</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="users" checked> <span data-i18n="settings.admin.backup_inc_users">Users (no passwords / 2FA / emails)</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="settings" checked> <span data-i18n="settings.admin.backup_inc_settings">Server settings</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="messages"> <span data-i18n="settings.admin.backup_inc_messages">Messages (chat history)</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="files"> <span data-i18n="settings.admin.backup_inc_files">Uploaded files</span></label>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button class="btn-sm btn-accent" id="backup-download-btn">⬇️ <span data-i18n="settings.admin.backup_download_btn">Download Backup</span></button>
<button class="btn-sm" id="backup-select-all-btn" type="button" data-i18n="settings.admin.backup_select_all">Select All</button>
<button class="btn-sm" id="backup-select-none-btn" type="button" data-i18n="settings.admin.backup_select_none">Clear</button>
</div>
<small class="settings-hint" style="margin-top:6px;display:block" data-i18n="settings.admin.backup_modes_hint">Pick what to include. Messages and uploaded files can make the backup very large; channels / users / settings alone are usually small. User passwords, 2FA secrets, and emails are never exported.</small>
</div>
<div class="settings-group" style="margin-top:14px">
<small class="settings-group-label" data-i18n="settings.admin.backup_restore_label">Restore From Backup</small>
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
<input type="file" id="backup-restore-file" accept=".zip" style="flex:1">
<button class="btn-sm" id="backup-restore-btn">⬆️ <span data-i18n="settings.admin.backup_restore_btn">Restore</span></button>
</div>
<small class="settings-hint" style="margin-top:6px;display:block;color:var(--danger,#e84a4a)" data-i18n="settings.admin.backup_restore_warning"><strong>Warning:</strong> Restoring overwrites the current server data. The server will restart automatically — your hosting setup must support auto-restart (Docker, systemd, the Haven installer service, etc.). The previous database is preserved as <code>haven.db.pre-restore</code> for one cycle.</small>
</div>
</div>
<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">📁 <span data-i18n="settings.admin.uploads_title">Uploads &amp; Limits</span></h5>
<label class="select-row">
@ -1797,7 +1915,7 @@
<!-- Discord Import Modal -->
<div class="modal-overlay" id="import-modal" style="display:none">
<div class="modal" style="max-width:560px">
<div class="modal" style="width:560px">
<h3>📦 <span data-i18n="modals.discord_import.title">Import from Discord</span></h3>
<!-- Step 1: Upload or Connect -->
@ -1946,7 +2064,7 @@
<!-- Assign Role Modal -->
<div class="modal-overlay" id="assign-role-modal" style="display:none">
<div class="modal" style="max-width:440px">
<div class="modal" style="width:440px">
<h3>👑 <span data-i18n="modals.assign_role.title">Assign Role</span></h3>
<p id="assign-role-user-label" class="muted-text"></p>
@ -2036,7 +2154,7 @@
<!-- Sound Management Modal (Full Popout) -->
<div class="modal-overlay" id="sound-modal" style="display:none">
<div class="modal" style="max-width:640px;min-height:420px">
<div class="modal" style="width:640px;min-height:420px">
<h3>🔊 <span data-i18n="modals.sound_manager.title">Sound Manager</span></h3>
<!-- Tabs -->
<div class="sound-modal-tabs">
@ -2105,7 +2223,7 @@
<!-- Emoji Management Modal -->
<div class="modal-overlay" id="emoji-modal" style="display:none">
<div class="modal" style="max-width:520px">
<div class="modal" style="width:520px">
<h3>😎 <span data-i18n="modals.emoji_mgmt.title">Emoji Management</span></h3>
<small class="settings-hint" style="display:block;margin-bottom:12px" data-i18n="settings.admin.emojis_upload_hint">Upload images for custom server emojis (max 256 KB, png/gif/webp). Name must be lowercase, no spaces.</small>
<div class="whitelist-add-row">
@ -2135,7 +2253,7 @@
<!-- Emoji Crop Modal -->
<div class="modal-overlay" id="emoji-crop-modal" style="display:none">
<div class="modal" style="max-width:320px;text-align:center">
<div class="modal" style="width:320px;text-align:center">
<h3 style="margin-bottom:6px">✂️ <span data-i18n="modals.emoji_crop.title">Crop Emoji</span></h3>
<small class="settings-hint" style="display:block;margin-bottom:10px" data-i18n="modals.emoji_crop.hint">Drag to reposition · Slider to zoom</small>
<canvas id="emoji-crop-canvas" width="256" height="256" style="border-radius:var(--radius);cursor:grab;display:block;margin:0 auto 10px;border:2px solid var(--accent);max-width:100%;touch-action:none"></canvas>
@ -2252,7 +2370,7 @@
<!-- Manage Servers Modal -->
<div class="modal-overlay" id="manage-servers-modal" style="display:none">
<div class="modal" style="max-width:480px">
<div class="modal manage-servers-modal-inner">
<h3><span data-i18n="modals.manage_servers.title">Manage Servers</span></h3>
<p class="modal-desc" data-i18n="modals.manage_servers.desc">Edit or remove your linked servers</p>
<div id="manage-servers-list" class="manage-servers-list"></div>
@ -2267,6 +2385,7 @@
<div id="channel-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="mark-read"><span data-i18n="context_menu.channel.mark_read">Mark as Read</span></button>
<button class="channel-ctx-item" data-action="mute">🔔 <span data-i18n="context_menu.channel.mute">Mute Channel</span></button>
<button class="channel-ctx-item" data-action="copy-channel-link">🔗 <span data-i18n="context_menu.channel.copy_link">Copy Channel Link</span></button>
<button class="channel-ctx-item" data-action="join-voice">🎙️ <span data-i18n="voice.join_ctx">Join Voice</span></button>
<button class="channel-ctx-item" data-action="leave-voice" style="display:none">📴 <span data-i18n="context_menu.channel.disconnect_voice">Disconnect Voice</span></button>
<hr class="channel-ctx-sep">
@ -2350,13 +2469,14 @@
<div id="dm-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="dm-mark-read"><span data-i18n="context_menu.dm.mark_read">Mark as Read</span></button>
<button class="channel-ctx-item" data-action="dm-mute">🔔 <span data-i18n="context_menu.dm.mute">Mute DM</span></button>
<button class="channel-ctx-item" data-action="dm-copy-link">🔗 <span data-i18n="context_menu.dm.copy_link">Copy DM Link</span></button>
<hr class="channel-ctx-sep">
<button class="channel-ctx-item danger" data-action="dm-delete">🗑️ <span data-i18n="context_menu.dm.delete">Delete DM</span></button>
</div>
<!-- Organize Sub-channels Modal -->
<div class="modal-overlay" id="organize-modal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal" style="width:500px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<button id="organize-back-btn" class="btn-sm" style="display:none;padding:3px 8px;font-size:0.8rem" data-i18n-title="modals.organize.back_title" title="Back to channel list"><span data-i18n="modals.organize.back_btn">← Back</span></button>
<h3 id="organize-modal-title" style="margin:0">📋 <span data-i18n="modals.organize.title">Organize</span></h3>
@ -2417,7 +2537,7 @@
<!-- DM Organize Modal -->
<div class="modal-overlay" id="dm-organize-modal" style="display:none">
<div class="modal" style="max-width:460px">
<div class="modal" style="width:460px">
<h3>📋 <span data-i18n="modals.dm_organize.title">Organize DMs</span></h3>
<p class="modal-desc" data-i18n="modals.dm_organize.desc">Tag your conversations into collapsible categories</p>
@ -2453,7 +2573,7 @@
<!-- Poll Creation Modal -->
<div class="modal-overlay" id="poll-modal" style="display:none">
<div class="modal" style="max-width:440px">
<div class="modal" style="width:440px">
<h3>📊 <span data-i18n="modals.poll.title">Create Poll</span></h3>
<div class="form-group">
<label data-i18n="modals.poll.question_label">Question</label>
@ -2526,7 +2646,7 @@
</div>
<div class="modal-overlay" id="webhook-modal" style="display:none">
<div class="modal" style="max-width:520px">
<div class="modal" style="width:520px">
<h3>🤖 <span data-i18n="modals.webhook.title">Webhooks</span></h3>
<p class="modal-desc" id="webhook-modal-channel-name"></p>
@ -2559,7 +2679,7 @@
<!-- Sub-channel Subscriptions Panel -->
<div class="modal-overlay" id="sub-panel-modal" style="display:none">
<div class="modal" style="max-width:520px;max-height:80vh;display:flex;flex-direction:column">
<div class="modal" style="width:520px;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<h3 style="margin:0;flex:1">📡 <span data-i18n="modals.sub_panel.title">Sub-channel Subscriptions</span></h3>
<button class="btn-sm" id="sub-panel-close-btn" data-i18n="modals.common.close" style="font-size:1rem;padding:4px 10px">&times;</button>
@ -2632,7 +2752,7 @@
</div>
<div class="modal-overlay" id="push-error-modal" style="display:none">
<div class="modal" style="max-width:420px;text-align:center;padding:28px 24px;">
<div class="modal" style="width:420px;text-align:center;padding:28px 24px;">
<div style="font-size:48px;margin-bottom:12px;">🔕</div>
<h3 style="margin:0 0 12px;color:var(--text-primary)" data-i18n="modals.push_error.title">Push Notifications Unavailable</h3>
<p id="push-error-reason" style="color:var(--text-secondary);font-size:14px;line-height:1.5;margin:0 0 16px;"></p>
@ -2757,13 +2877,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
</button>
</div>
<script src="/js/theme.js?v=2.7.3"></script>
<script src="/js/notifications.js?v=2.6.0"></script>
<script src="/js/servers.js?v=2.6.0"></script>
<script src="/js/voice.js?v=2.6.0"></script>
<script src="/js/modmode.js?v=2.6.0"></script>
<script src="/js/e2e.js?v=2.6.0"></script>
<script type="module" src="/js/app.js?v=2.7.11"></script>
<script src="/js/plugin-loader.js?v=2.6.0"></script>
<script src="/js/theme.js?v=3.4.0"></script>
<script src="/js/notifications.js?v=3.4.0"></script>
<script src="/js/servers.js?v=3.4.0"></script>
<script src="/js/voice.js?v=3.4.0"></script>
<script src="/js/modmode.js?v=3.4.0"></script>
<script src="/js/e2e.js?v=3.4.0"></script>
<script type="module" src="/js/app.js?v=3.4.0"></script>
<script src="/js/plugin-loader.js?v=3.4.0"></script>
</body>
</html>

View file

@ -5031,6 +5031,10 @@ select.cfn-select.cfn-input {
}
/* Manage Servers modal list */
.manage-servers-modal-inner {
width: 480px;
max-width: 90vw;
}
.manage-servers-list {
max-height: 360px;
overflow-y: auto;
@ -5141,6 +5145,61 @@ select.cfn-select.cfn-input {
color: var(--danger);
}
/* Sync Servers button in server bar */
.server-icon.sync-servers {
background: transparent;
border: 2px dashed var(--border-light);
margin-top: 2px;
}
.server-icon.sync-servers:hover {
border-color: var(--accent);
background: transparent;
border-radius: 12px;
}
.server-icon.sync-servers .server-icon-text {
font-size: 20px;
color: var(--text-secondary);
transition: color 0.15s;
}
.server-icon.sync-servers:hover .server-icon-text {
color: var(--accent);
}
.server-icon.sync-servers.spinning .server-icon-text {
animation: syncSpin 0.8s linear infinite;
color: var(--accent);
}
@keyframes syncSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Drag handle in Manage Servers list */
.manage-server-drag-handle {
cursor: grab;
color: var(--text-muted);
font-size: 16px;
line-height: 1;
user-select: none;
flex-shrink: 0;
padding: 0 2px;
letter-spacing: 1px;
opacity: 0.5;
transition: opacity 0.15s, color 0.15s;
}
.manage-server-drag-handle:hover {
opacity: 1;
color: var(--text-primary);
}
.manage-server-row.dragging {
opacity: 0.4;
}
.manage-server-row.drag-over-above {
box-shadow: 0 -2px 0 0 var(--accent);
}
.manage-server-row.drag-over-below {
box-shadow: 0 2px 0 0 var(--accent);
}
/*
MODAL
@ -5171,8 +5230,8 @@ select.cfn-select.cfn-input {
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 28px;
width: 90%;
max-width: 400px;
width: min(90%, 400px);
max-width: 90vw;
max-height: 85vh;
box-shadow: 0 12px 48px rgba(0,0,0,0.5);
resize: both;
@ -6323,6 +6382,51 @@ select.cfn-select.cfn-input {
color: var(--text-primary);
}
/* ── Reaction popout (who reacted) ────────────────────── */
.reaction-popout {
position: fixed;
z-index: 9999;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 140px;
max-width: 240px;
max-height: 240px;
overflow-y: auto;
animation: popout-in 0.12s ease-out;
}
@keyframes popout-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.reaction-popout-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 4px;
font-size: 16px;
border-bottom: 1px solid var(--border-light);
}
.reaction-popout-count {
font-size: 12px;
color: var(--text-muted);
}
.reaction-popout-list {
padding: 4px 0;
}
.reaction-popout-user {
padding: 4px 12px;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reaction-popout-user:hover {
background: var(--bg-tertiary);
}
.reaction-picker {
position: absolute;
top: -44px;
@ -6402,8 +6506,8 @@ select.cfn-select.cfn-input {
/* ── Full Reaction Emoji Picker (opened via "...") ── */
.reaction-full-picker {
position: absolute;
bottom: 100%;
right: 8px;
bottom: calc(100% + 6px);
right: 0;
width: 320px;
max-height: 340px;
display: flex;
@ -6498,6 +6602,52 @@ select.cfn-select.cfn-input {
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 10;
}
.msg-toolbar-group {
display: flex;
gap: 2px;
}
.msg-toolbar-more,
.thread-msg-more {
position: relative;
display: flex;
}
.msg-toolbar-overflow,
.thread-msg-overflow {
position: absolute;
top: auto;
bottom: 100%;
right: 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 36px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
z-index: 12;
opacity: 0;
visibility: hidden;
transform: translateY(4px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease, visibility 0s linear 180ms;
}
.msg-toolbar-overflow.flip-below,
.thread-msg-overflow.flip-below {
bottom: auto;
top: 100%;
}
.msg-toolbar-more:hover .msg-toolbar-overflow,
.msg-toolbar-more:focus-within .msg-toolbar-overflow,
.thread-msg-more:hover .thread-msg-overflow,
.thread-msg-more:focus-within .thread-msg-overflow {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
transition-delay: 0s;
}
/* Only show toolbar on real mouse hover not on touch-triggered :hover.
Require BOTH hover:hover AND pointer:fine so mobile browsers that
@ -6518,15 +6668,58 @@ select.cfn-select.cfn-input {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
color: var(--text-muted);
transition: background var(--transition);
}
.msg-toolbar button:hover { background: var(--bg-tertiary); }
.msg-toolbar button:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.msg-toolbar button svg,
.thread-msg-toolbar button svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
}
.tb-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tb-icon-emoji {
font-size: 14px;
}
.tb-icon-mono {
display: inline-flex;
}
:root[data-toolbaricons="emoji"] .tb-icon-emoji,
html[data-toolbaricons="emoji"] .tb-icon-emoji,
:root[data-toolbaricons="color"] .tb-icon-emoji,
html[data-toolbaricons="color"] .tb-icon-emoji {
display: inline-flex;
}
:root[data-toolbaricons="emoji"] .tb-icon-mono,
html[data-toolbaricons="emoji"] .tb-icon-mono,
:root[data-toolbaricons="color"] .tb-icon-mono,
html[data-toolbaricons="color"] .tb-icon-mono {
display: none;
}
:root:not([data-toolbaricons="emoji"]):not([data-toolbaricons="color"]) .tb-icon-emoji,
html:not([data-toolbaricons="emoji"]):not([data-toolbaricons="color"]) .tb-icon-emoji {
display: none;
}
:root:not([data-toolbaricons="emoji"]):not([data-toolbaricons="color"]) .tb-icon-mono,
html:not([data-toolbaricons="emoji"]):not([data-toolbaricons="color"]) .tb-icon-mono {
display: inline-flex;
}
/* First message: flip toolbar below so it's not clipped by the scroll
container's top edge (hidden behind the channel-topic bar). */
@ -6542,12 +6735,92 @@ select.cfn-select.cfn-input {
.messages > :first-child .reaction-full-picker,
.reaction-full-picker.flip-below {
bottom: auto;
top: 100%;
top: calc(100% + 6px);
margin-bottom: 0;
margin-top: 4px;
margin-top: 0;
}
.toolbar-slots-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
font-size: 12px;
}
.toolbar-slots-row input[type="range"] {
flex: 1 1 180px;
min-width: 0;
}
.toolbar-slots-value {
min-width: 14px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.toolbar-order-wrap {
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-primary);
}
.toolbar-order-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
}
.toolbar-order-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.toolbar-order-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-tertiary);
}
.toolbar-order-item-label {
font-size: 12px;
color: var(--text-primary);
}
.toolbar-order-item-controls {
display: flex;
gap: 4px;
}
.toolbar-order-move {
min-width: 26px;
height: 24px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
}
.toolbar-order-move:disabled {
opacity: 0.45;
cursor: default;
}
/*
REPLY BANNER (on messages + input area)
*/
@ -6621,6 +6894,376 @@ select.cfn-select.cfn-input {
.reply-bar-close:hover { color: var(--text-primary); background: var(--bg-secondary); }
/*
THREAD PREVIEW (under messages)
*/
.thread-preview {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 4px 10px;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
color: var(--accent);
font-weight: 600;
transition: background 0.15s;
}
.thread-preview:hover {
background: var(--bg-tertiary);
}
.thread-participant-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.thread-participant-avatar + .thread-participant-avatar {
margin-left: -6px;
}
.thread-participant-initial {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: #fff;
}
.thread-preview-count {
margin-left: 4px;
}
.thread-preview-time {
color: var(--text-muted);
font-weight: 400;
margin-left: 4px;
}
.thread-preview-arrow {
font-size: 16px;
opacity: 0;
transition: opacity 0.15s;
}
.thread-preview:hover .thread-preview-arrow { opacity: 1; }
/*
THREAD PANEL (right side)
*/
.thread-panel {
position: fixed;
top: 0;
right: 0;
bottom: var(--thread-footer-offset, 0px);
width: 380px;
min-width: 300px;
max-width: min(78vw, 920px);
max-width: 100vw;
background: var(--bg-primary);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
z-index: 800;
box-shadow: -4px 0 24px rgba(0,0,0,0.25);
animation: thread-slide-in 0.2s ease-out;
}
.thread-panel-resizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8px;
cursor: ew-resize;
z-index: 2;
}
.thread-panel.pip {
top: auto;
right: 14px;
bottom: calc(var(--thread-footer-offset, 0px) + 14px);
width: min(420px, calc(100vw - 28px));
height: min(58vh, 520px);
max-height: calc(100vh - var(--thread-footer-offset, 0px) - 28px);
border: 1px solid var(--border-light);
border-radius: 12px;
resize: both;
overflow: hidden;
z-index: 10020;
}
.thread-panel.pip .thread-panel-resizer {
display: none;
}
@keyframes thread-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.thread-panel-header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.thread-panel-header-top {
display: flex;
align-items: center;
gap: 8px;
}
.thread-panel.pip .thread-panel-header-top {
cursor: move;
}
.thread-panel-icon { font-size: 18px; }
.thread-panel-title {
flex: 1;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.thread-parent-meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.thread-parent-avatar-wrap {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.thread-parent-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.thread-parent-avatar.thread-parent-avatar-square {
border-radius: 6px;
}
.thread-parent-avatar-initial {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
}
.thread-parent-avatar-initial.thread-parent-avatar-square {
border-radius: 6px;
}
.thread-parent-name {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-parent-preview {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
}
.thread-message {
margin-bottom: 10px;
position: relative;
border-radius: var(--radius-sm);
transition: background var(--transition), box-shadow var(--transition);
}
.thread-message:hover {
background: rgba(255,255,255,0.02);
box-shadow: var(--msg-glow);
}
.thread-message.thread-highlight {
background: rgba(255,255,255,0.035);
box-shadow: var(--msg-glow);
}
.thread-msg-row {
display: flex;
gap: 10px;
padding: 4px 8px;
}
.thread-msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.thread-msg-avatar-initial {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
}
.thread-msg-body { flex: 1; min-width: 0; }
.thread-msg-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.thread-msg-header-spacer { flex: 1; }
.thread-msg-toolbar {
display: none;
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2px 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.thread-message:hover .thread-msg-toolbar,
.thread-message.showing-picker .thread-msg-toolbar {
display: inline-flex;
}
.thread-message.editing .thread-msg-toolbar {
display: none !important;
}
.thread-msg-toolbar button {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
color: var(--text-muted);
}
.thread-msg-toolbar button:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.thread-action-react-icon {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
}
.thread-msg-author {
font-size: 13px;
font-weight: 600;
}
.thread-msg-time {
font-size: 11px;
color: var(--text-muted);
}
.thread-msg-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
word-wrap: break-word;
}
.thread-msg-actions {
margin-left: auto;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.thread-message:hover .thread-msg-actions,
.thread-message.showing-picker .thread-msg-actions {
opacity: 1;
pointer-events: auto;
}
.thread-react-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
padding: 0;
cursor: pointer;
}
.thread-react-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.thread-react-btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.thread-input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
}
.thread-reply-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
border-top: 1px solid var(--border-light);
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-muted);
}
#thread-reply-preview-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-desktop-app] {
--thread-footer-offset: 35px;
}
[data-desktop-app][data-hide-statusbar] {
--thread-footer-offset: 0px;
}
.thread-input {
flex: 1;
resize: none;
border: 1px solid var(--border-light);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
max-height: 120px;
overflow-y: auto;
}
.thread-input:focus { outline: none; border-color: var(--accent); }
.thread-send-btn {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.thread-send-btn:hover { filter: brightness(1.15); }
/*
IMAGE QUEUE BAR (paste/drop preview before sending)
*/
@ -7028,6 +7671,31 @@ select.cfn-select.cfn-input {
animation: highlightFlash 2s ease;
}
/* ── NEW MESSAGES divider ── */
.new-messages-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 16px;
user-select: none;
pointer-events: none;
}
.new-messages-divider::before,
.new-messages-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--accent, #5865f2);
opacity: 0.6;
}
.new-messages-divider span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent, #5865f2);
white-space: nowrap;
}
/*
EDITED MESSAGE TAG
@ -7704,6 +8372,7 @@ select.cfn-select.cfn-input {
}
.modal-settings {
width: 90%;
max-width: 640px;
max-height: 85vh;
display: flex;
@ -9059,6 +9728,73 @@ select.cfn-select.cfn-input {
}
/*
iOS / MOBILE SAFE-AREA HARDENING
JS probes env(safe-area-inset-*) and sets --safe-top /
--safe-bottom as CSS custom properties with device-specific
minimum floors. These override env() when the browser
returns 0 despite a notch / Dynamic Island being present.
*/
@media (max-width: 768px) {
/* Header: use JS-measured safe-top when available, falling back to env() */
body.is-ios .channel-header,
body.is-android .channel-header {
padding-top: calc(8px + var(--safe-top, env(safe-area-inset-top, 0px))) !important;
min-height: calc(48px + var(--safe-top, env(safe-area-inset-top, 0px))) !important;
}
/* Left sidebar overlay */
body.is-ios .sidebar,
body.is-android .sidebar {
padding-top: var(--safe-top, env(safe-area-inset-top, 0px)) !important;
}
/* Right sidebar */
body.is-ios .right-sidebar,
body.is-android .right-sidebar {
padding-top: var(--safe-top, env(safe-area-inset-top, 0px)) !important;
}
/* Server bar */
body.is-ios .server-bar,
body.is-android .server-bar {
padding-top: calc(8px + var(--safe-top, env(safe-area-inset-top, 0px))) !important;
}
/* Message input — bottom safe area */
body.is-ios .message-input-area,
body.is-android .message-input-area {
padding-bottom: calc(8px + var(--safe-bottom, env(safe-area-inset-bottom, 0px))) !important;
}
/* Toasts */
body.is-ios #toast-container,
body.is-android #toast-container {
top: calc(8px + var(--safe-top, env(safe-area-inset-top, 0px)));
}
}
@media (max-width: 480px) {
body.is-ios .channel-header,
body.is-android .channel-header {
padding-top: calc(6px + var(--safe-top, env(safe-area-inset-top, 0px))) !important;
min-height: calc(44px + var(--safe-top, env(safe-area-inset-top, 0px))) !important;
}
body.is-ios .message-input-area,
body.is-android .message-input-area {
padding-bottom: calc(6px + var(--safe-bottom, env(safe-area-inset-bottom, 0px))) !important;
}
}
/* Remove safe-top padding when iOS keyboard is open (bottom padding already
handled by .ios-keyboard-open rule above) */
body.is-ios.ios-keyboard-open .message-input-area {
padding-bottom: 6px !important;
}
/*
TOUCH & ACCESSIBILITY
*/
@ -10260,10 +10996,22 @@ video:-webkit-full-screen {
.chat-blockquote {
border-left: 3px solid var(--text-muted);
padding: 2px 0 2px 10px;
margin: 4px 0;
padding: 4px 10px;
margin: 1px 0;
color: var(--text-secondary);
font-style: italic;
background: var(--bg-tertiary);
border-radius: 6px;
max-width: fit-content;
}
.chat-blockquote-author {
font-style: normal;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 1px;
}
.chat-blockquote-body {
line-height: 1.35;
}
/* ── Chat Markdown: Highlight ──────────────────────────── */

View file

@ -76,7 +76,8 @@
<div id="sso-step-server">
<div class="form-group">
<label for="sso-server-url" data-i18n="auth.sso.server_label">Your Haven server</label>
<input type="text" id="sso-server-url" placeholder="haven.example.com" required>
<input type="text" id="sso-server-url" list="sso-recent-servers" placeholder="haven.example.com" required>
<datalist id="sso-recent-servers"></datalist>
<small data-i18n="auth.sso.server_hint">Enter the address of the Haven server you already have an account on</small>
</div>
<button type="button" class="btn-primary" id="sso-connect-btn" data-i18n="auth.sso.connect">Connect</button>

View file

@ -5,16 +5,16 @@
// ═══════════════════════════════════════════════════════════
import SocketMethods from './modules/app-socket.js?v=2.7.9';
import UIBindMethods from './modules/app-ui.js?v=2.7.0';
import UIBindMethods from './modules/app-ui.js?v=2.7.11';
import MediaMethods from './modules/app-media.js?v=2.7.0';
import ContextMethods from './modules/app-context.js?v=2.7.3';
import ContextMethods from './modules/app-context.js?v=2.7.11';
import ChannelMethods from './modules/app-channels.js?v=2.7.8';
import MessageMethods from './modules/app-messages.js?v=2.7.10';
import MessageMethods from './modules/app-messages.js?v=2.7.11';
import UserMethods from './modules/app-users.js?v=2.7.0';
import VoiceMethods from './modules/app-voice.js?v=2.7.10';
import UtilityMethods from './modules/app-utilities.js?v=2.7.9';
import UtilityMethods from './modules/app-utilities.js?v=2.7.11';
import AdminMethods from './modules/app-admin.js?v=2.7.0';
import PlatformMethods from './modules/app-platform.js?v=2.7.8';
import PlatformMethods from './modules/app-platform.js?v=2.7.11';
class HavenApp {
constructor() {
@ -32,6 +32,9 @@ class HavenApp {
this.serverManager = new ServerManager();
this.notifications = new NotificationManager();
this.replyingTo = null; // message object being replied to
this._threadReplyingTo = null; // thread message being replied to
this._activeThreadParent = null; // currently open thread parent message ID
this._lastMoveSelectedEl = null; // last clicked message in move-selection mode
this._imageQueue = []; // queued images awaiting send
this.channelMembers = []; // for @mention autocomplete
this.mentionQuery = ''; // current partial @mention being typed
@ -95,11 +98,11 @@ class HavenApp {
'Monkeys': ['🙈','🙉','🙊','🐵','🐒','🦍','🦧'],
'Animals': ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐔','🐧','🐦','🦆','🦅','🦉','🐺','🐴','🦄','🐝','🦋','🐌','🐞','🐢','🐍','🐙','🐬','🐳','🦈','🐊','🦖','🦕','🐋','🦭','🦦','🦫','🦥','🐿️','🦔','🦇','🐓','🦃','🦚','🦜','🦢','🦩','🐕','🐈','🐈‍⬛'],
'Faces': ['👀','👁️','👁️‍🗨️','👅','👄','🫦','💋','🧠','🦷','🦴','👃','👂','🦻','🦶','🦵','💀','☠️','👽','🤖','🎃','😺','😸','😹','😻','😼','😽','🙀','😿','😾'],
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','<EFBFBD>','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','<EFBFBD>','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','🍻','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','🎶','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
'Travel': ['🚗','🚕','🚀','✈️','🚁','🛸','🚢','🏠','🏢','🏰','🗼','🗽','⛩️','🌋','🏔️','🌊','🌅','🌄','🌉','🎡','🎢','🗺️','🧭','🏖️','🏕️','🌍','🌎','🌏','🛳️','⛵','🚂','🚇','🏎️','🏍️','🛵','🛶'],
'Objects': ['⌚','📱','💻','⌨️','🖥️','💾','📷','🔭','🔬','💡','🔦','📚','📝','✏️','📎','📌','🔑','🔒','🔓','🛡️','⚔️','🔧','💰','💎','📦','🎁','✉️','🔔','🪙','💸','🏷️','🔨','🪛','🧲','🧪','🧫','💊','🩺','🩹','🧬','💬','💭','🗨️','🗯️','📣','📢','🔊','🔇','📰','🗞️','📋','📁','📂','🗂️','📅','📆','🗓️','🖊️','🖋️','✒️','📏','📐','🗑️','👑','💍','👒','🎩','🧢','👓','🕶️','🧳','🌂','☂️'],
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','','','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️‍🌈','🏴‍☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0⃣','1⃣','2⃣','3⃣','4⃣','5⃣','6⃣','7⃣','8⃣','9⃣','🔟','©️','®️','™️']
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','!','?',',','.','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','','','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️‍🌈','🏴‍☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0⃣','1⃣','2⃣','3⃣','4⃣','5⃣','6⃣','7⃣','8⃣','9⃣','🔟','©️','®️','™️']
};
// Flat list for quick access (used by search)
@ -110,16 +113,19 @@ class HavenApp {
'😀':'grinning happy','😁':'beaming grin','😂':'joy tears laughing lol','🤣':'rofl rolling laughing','😃':'smiley happy','😄':'smile happy','😅':'sweat nervous','😆':'laughing satisfied','😉':'wink','😊':'blush happy shy','😋':'yummy delicious','😎':'cool sunglasses','😍':'heart eyes love','🥰':'loving smiling hearts','😘':'kiss blowing','🙂':'slight smile','🤗':'hug hugging open hands','🤩':'starstruck star eyes','🤔':'thinking hmm','😐':'neutral expressionless','🙄':'eye roll','😏':'smirk','😣':'persevere','😥':'sad relieved disappointed','😮':'open mouth wow surprised','😯':'hushed surprised','😴':'sleeping zzz','😛':'tongue playful','😜':'wink tongue crazy','😝':'squinting tongue','😒':'unamused','😔':'pensive sad','🙃':'upside down','😲':'astonished shocked','😤':'triumph huff angry steam','😭':'crying sob loudly','😢':'cry sad tear','😱':'scream fear horrified','🥺':'pleading puppy eyes please','😠':'angry mad','😡':'rage pouting furious','🤬':'cursing swearing angry','😈':'devil smiling imp','💀':'skull dead','💩':'poop poo','🤡':'clown','👻':'ghost boo','😺':'cat smile','😸':'cat grin','🫠':'melting face','🫣':'peeking eye','🫢':'hand over mouth','🫥':'dotted line face','🫤':'diagonal mouth','🥹':'holding back tears','🥲':'smile tear','😶‍🌫️':'face in clouds','🤭':'giggling hand over mouth','🫡':'salute','🤫':'shush quiet secret','🤥':'lying pinocchio','😬':'grimace awkward','🫨':'shaking face','😵':'dizzy','😵‍💫':'face spiral eyes','🥴':'woozy drunk','😮‍💨':'exhale sigh relief','🥱':'yawn tired boring','😇':'angel innocent halo','🤠':'cowboy yeehaw','🤑':'money face rich','🤓':'nerd glasses','👿':'devil angry imp','🫶':'heart hands','🤧':'sneeze sick','😷':'mask sick','🤒':'thermometer sick','🤕':'bandage hurt','💅':'nail polish sassy',
'👋':'wave hello hi bye','🤚':'raised back hand','✋':'hand stop high five','🖖':'vulcan spock','👌':'ok okay perfect','🤌':'pinched italian','✌️':'peace victory','🤞':'crossed fingers luck','🤟':'love you hand','🤘':'rock on metal','🤙':'call me shaka hang loose','👈':'point left','👉':'point right','👆':'point up','👇':'point down','☝️':'index up','👍':'thumbs up like good yes','👎':'thumbs down dislike bad no','✊':'fist bump','👊':'punch fist bump','🤛':'left fist bump','🤜':'right fist bump','👏':'clap applause','🙌':'raising hands celebrate','🤝':'handshake deal','🙏':'pray please thank you namaste','💪':'strong muscle flex bicep','💃':'dancer dancing woman','🕺':'man dancing','🤳':'selfie','🖕':'middle finger','🫰':'pinch','🫳':'palm down','🫴':'palm up','👐':'open hands','🤲':'palms up','🫱':'right hand','🫲':'left hand','🤷':'shrug idk','🤦':'facepalm','🙇':'bow','💁':'info','🙆':'ok gesture','🙅':'no gesture','🙋':'raising hand hi','🧏':'deaf',
'🐶':'dog puppy','🐱':'cat kitty','🐭':'mouse','🐹':'hamster','🐰':'rabbit bunny','🦊':'fox','🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow','🐷':'pig','🐸':'frog','🐔':'chicken','🐧':'penguin','🐦':'bird','🦆':'duck','🦅':'eagle','🦉':'owl','🐺':'wolf','🐴':'horse','🦄':'unicorn','🐝':'bee','🦋':'butterfly','🐌':'snail','🐞':'ladybug','🐢':'turtle','🐍':'snake','🐙':'octopus','🐬':'dolphin','🐳':'whale','🦈':'shark','🐊':'crocodile alligator','🦖':'trex dinosaur','🦕':'dinosaur brontosaurus',
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','<EFBFBD>':'clinking beers cheers','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','<EFBFBD>':'music notes','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','🍻':'clinking beers cheers toast','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','🎶':'music notes melody song','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
'🚗':'car automobile','🚀':'rocket space launch','✈️':'airplane plane travel','🏠':'house home','🏰':'castle','🌊':'wave ocean water','🌅':'sunrise','🌍':'globe earth world','🌈':'rainbow',
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','!':'exclamation punctuation bang','?':'question punctuation mark',',':'comma punctuation','.':'period punctuation dot','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
'🙈':'see no evil monkey','🙉':'hear no evil monkey','🙊':'speak no evil monkey',
'👀':'eyes looking','👅':'tongue','👄':'mouth lips','💋':'kiss lips','🧠':'brain smart','🦷':'tooth','🦴':'bone','💀':'skull dead','☠️':'skull crossbones','👽':'alien','🤖':'robot','🎃':'jack o lantern pumpkin halloween',
'📱':'phone mobile','💻':'laptop computer','📷':'camera photo','📚':'books reading','📝':'memo note write','🔑':'key','🔒':'lock locked','💎':'gem diamond jewel','🎁':'gift present','🔔':'bell notification','💰':'money bag rich','🔨':'hammer tool','💬':'speech bubble chat','💭':'thought bubble thinking','🗨️':'speech balloon','🗯️':'anger bubble','📣':'megaphone announcement','📢':'loudspeaker','👑':'crown king queen royal','💍':'ring diamond wedding','🕶️':'sunglasses cool'
};
if (!this.token || !this.user) {
// Preserve invite param so it survives the redirect to the auth page
const _inv = new URLSearchParams(window.location.search).get('invite');
if (_inv) sessionStorage.setItem('haven_pending_invite', _inv);
window.location.href = '/';
return;
}
@ -181,6 +187,7 @@ class HavenApp {
this._setupEmojiSizePicker();
this._setupImageModePicker();
this._setupRoleDisplayPicker();
this._setupToolbarIconPicker();
this._setupLightbox();
this._setupOnlineOverlay();
this._setupModalExpand();

View file

@ -1,9 +1,28 @@
// ── Auth Page Logic (with theme support + i18n) ───────────────────────────
(async function () {
// Preserve invite param across login/register so vanity invite links work for new users
const _urlParams = new URLSearchParams(window.location.search);
const _pendingInvite = _urlParams.get('invite') || sessionStorage.getItem('haven_pending_invite') || '';
if (_pendingInvite) sessionStorage.setItem('haven_pending_invite', _pendingInvite);
// Preserve channel/message deep-link params (?channel=CODE&message=ID) too
const _pendingChannel = _urlParams.get('channel') || sessionStorage.getItem('haven_pending_channel') || '';
const _pendingMessage = _urlParams.get('message') || sessionStorage.getItem('haven_pending_message') || '';
if (_pendingChannel) sessionStorage.setItem('haven_pending_channel', _pendingChannel);
if (_pendingMessage) sessionStorage.setItem('haven_pending_message', _pendingMessage);
const _appQuery = (() => {
const parts = [];
if (_pendingInvite) parts.push('invite=' + encodeURIComponent(_pendingInvite));
if (_pendingChannel) parts.push('channel=' + encodeURIComponent(_pendingChannel));
if (_pendingMessage) parts.push('message=' + encodeURIComponent(_pendingMessage));
return parts.length ? '?' + parts.join('&') : '';
})();
const _appUrl = '/app' + _appQuery;
// If already logged in, redirect to app
if (localStorage.getItem('haven_token')) {
window.location.href = '/app';
window.location.href = _appUrl;
return;
}
@ -191,7 +210,7 @@
sessionStorage.setItem('haven_e2e_wrap', e2eWrap);
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
window.location.href = '/app';
window.location.href = _appUrl;
} catch {
showError(t('auth.errors.connection_error'));
}
@ -292,7 +311,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
@ -325,7 +344,7 @@
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
_pendingChallenge = null;
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
@ -368,10 +387,28 @@
// ── SSO (Link Server) ──────────────────────────────────
if (ssoForm) {
// Populate the recent-servers datalist from localStorage
try {
const servers = JSON.parse(localStorage.getItem('haven_servers') || '[]');
const datalist = document.getElementById('sso-recent-servers');
if (datalist && Array.isArray(servers)) {
for (const s of servers) {
if (s.url) {
const opt = document.createElement('option');
opt.value = s.url;
if (s.name) opt.label = s.name;
datalist.appendChild(opt);
}
}
}
} catch { /* ignore */ }
let ssoAuthCode = null;
let ssoServerUrl = null;
let ssoProfileData = null;
let ssoWaiting = false;
let ssoPollTimer = null;
let ssoTimeoutTimer = null;
const ssoConnectBtn = document.getElementById('sso-connect-btn');
const ssoStepServer = document.getElementById('sso-step-server');
@ -382,7 +419,71 @@
const ssoBackBtn = document.getElementById('sso-back-btn');
const ssoServerInput = document.getElementById('sso-server-url');
const stopSsoPolling = () => {
if (ssoPollTimer) {
clearInterval(ssoPollTimer);
ssoPollTimer = null;
}
if (ssoTimeoutTimer) {
clearTimeout(ssoTimeoutTimer);
ssoTimeoutTimer = null;
}
};
const getSsoOrigin = () => {
try { return new URL(ssoServerUrl).origin; } catch { return ssoServerUrl; }
};
const applySsoProfile = (profile, sourceOrigin = null) => {
if (!profile) return;
ssoProfileData = profile;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
const profileUsername = (typeof ssoProfileData.username === 'string' ? ssoProfileData.username.trim() : '');
const previewName = (typeof ssoProfileData.displayName === 'string' ? ssoProfileData.displayName.trim() : '') || profileUsername;
if (ssoProfileData.profilePicture) {
let src = ssoProfileData.profilePicture;
if (src.startsWith('/')) {
const base = sourceOrigin || getSsoOrigin();
src = base + src;
}
ssoPreviewAvatar.innerHTML = `<img src="${src}" style="width:100%;height:100%;object-fit:cover" alt="">`;
} else {
ssoPreviewAvatar.textContent = (previewName || '?')[0].toUpperCase();
}
ssoPreviewUsername.textContent = previewName || '—';
ssoStepServer.style.display = 'none';
ssoStepRegister.style.display = '';
hideError();
};
const tryFetchSsoProfile = async (surfaceError = false) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return false;
try {
const res = await fetch(`${ssoServerUrl}/api/auth/SSO/authenticate?authCode=${encodeURIComponent(ssoAuthCode)}`);
if (!res.ok) {
if (surfaceError && res.status !== 404) {
const data = await res.json().catch(() => ({}));
showError(data.error || 'SSO failed — please try again');
}
return false;
}
const data = await res.json();
applySsoProfile(data, getSsoOrigin());
return true;
} catch {
if (surfaceError) showError('Could not reach home server — please try again');
return false;
}
};
function ssoReset() {
stopSsoPolling();
ssoAuthCode = null;
ssoServerUrl = null;
ssoProfileData = null;
@ -423,44 +524,39 @@
ssoWaiting = true;
ssoConnectBtn.textContent = 'Waiting for approval…';
ssoConnectBtn.disabled = true;
stopSsoPolling();
ssoPollTimer = setInterval(() => {
tryFetchSsoProfile(false);
}, 2000);
ssoTimeoutTimer = setTimeout(() => {
if (!ssoWaiting) return;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
showError('SSO approval timed out — try connecting again');
}, 90000);
});
// When user returns to this tab after approving on home server
window.addEventListener('focus', async () => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
ssoWaiting = false;
await tryFetchSsoProfile(true);
});
try {
const res = await fetch(`${ssoServerUrl}/api/auth/SSO/authenticate?authCode=${encodeURIComponent(ssoAuthCode)}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
return showError(data.error || 'SSO failed — please try again');
}
// Preferred path: SSO popup posts profile data back to this window.
window.addEventListener('message', (event) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
const data = event.data || {};
if (data.type !== 'haven-sso-approved') return;
if (data.authCode !== ssoAuthCode) return;
ssoProfileData = await res.json();
const expectedOrigin = getSsoOrigin();
if (event.origin !== expectedOrigin) return;
// Show the profile preview
if (ssoProfileData.profilePicture) {
let src = ssoProfileData.profilePicture;
if (src.startsWith('/')) src = ssoServerUrl + src;
ssoPreviewAvatar.innerHTML = `<img src="${src}" style="width:100%;height:100%;object-fit:cover" alt="">`;
} else {
ssoPreviewAvatar.textContent = (ssoProfileData.username || '?')[0].toUpperCase();
}
ssoPreviewUsername.textContent = ssoProfileData.username || '—';
// Switch to step 2
ssoStepServer.style.display = 'none';
ssoStepRegister.style.display = '';
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
} catch (err) {
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
showError('Could not reach home server — please try again');
}
if (!data.profile || !data.profile.username) return;
applySsoProfile(data.profile, data.serverOrigin || expectedOrigin);
});
// Back button — return to step 1
@ -482,6 +578,25 @@
if (password.length < 8) return showError(t('auth.errors.password_too_short'));
if (password !== confirm) return showError(t('auth.errors.passwords_no_match'));
// Prefer canonical username from SSO payload. If a legacy server sends
// display-name-like values, normalize into a valid Haven username.
const normalizeUsername = (value) => {
if (typeof value !== 'string') return '';
return value
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 20);
};
let registerUsername = normalizeUsername(ssoProfileData.username);
if (registerUsername.length < 3) {
registerUsername = normalizeUsername(ssoProfileData.displayName);
}
if (registerUsername.length < 3) {
return showError('SSO username is invalid. Please use standard registration.');
}
// Build the full profile picture URL for the server to download
let profilePicUrl = ssoProfileData.profilePicture || null;
if (profilePicUrl && profilePicUrl.startsWith('/')) {
@ -493,7 +608,7 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: ssoProfileData.username,
username: registerUsername,
password,
eulaVersion: '2.0',
ageVerified: true,
@ -511,7 +626,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
@ -549,7 +664,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}

View file

@ -68,38 +68,72 @@ class HavenE2E {
await this._openDB();
this._keysWereReset = false;
this._serverBackupExists = false;
this._serverBackupState = 'unknown'; // 'present' | 'none' | 'unknown'
this._divergent = false; // local pub != server pub
this._ghostState = false; // init aborted to protect a possibly-good server backup
/* 1. Fast path — local IndexedDB */
this._keyPair = await this._loadLocal();
/* 1b. If loaded from IndexedDB but server backup is gone (e.g. after
* account recovery), re-upload so cross-device sync and the public
* key endpoint stay valid. Uses the current wrapping key. */
/* 1b. If loaded from IndexedDB, probe server state explicitly.
* Three outcomes:
* present verify local pub matches server pub; flag divergence if not
* none server actually has no backup; re-upload ours
* unknown request timed out; do NOT mutate server state */
if (this._keyPair && socket && wrappingKey) {
const backup = await this._fetchBackup(socket);
if (backup) {
const probe = await this._fetchBackupWithState(socket);
this._serverBackupState = probe.status;
if (probe.status === 'present') {
this._serverBackupExists = true;
} else {
// Server backup was cleared — re-upload with the current wrapping key
try { await this._uploadBackup(socket, wrappingKey); }
try {
const localPub = await crypto.subtle.exportKey('jwk', this._keyPair.publicKey);
if (probe.serverPublicKey && localPub && probe.serverPublicKey.x && localPub.x && probe.serverPublicKey.x !== localPub.x) {
this._divergent = true;
console.warn('[E2E] Local key diverges from server backup — awaiting user action');
}
} catch { /* best-effort divergence check */ }
} else if (probe.status === 'none') {
// Server confirmed empty — safe to re-upload our local key
try { await this._uploadBackup(socket, wrappingKey); this._serverBackupExists = true; }
catch (err) { console.warn('[E2E] Re-upload after recovery failed:', err.message); }
} else {
// probe.status === 'unknown' — flaky network. Do NOT upload; it would
// clobber whatever the server actually has.
console.warn('[E2E] Could not reach server for backup probe — skipping re-upload to avoid clobber');
}
}
/* 2. Cross-device — try server backup (only if we have a wrapping key) */
if (!this._keyPair && socket && wrappingKey) {
this._keyPair = await this._restoreFromServer(socket, wrappingKey);
const restored = await this._restoreFromServerWithState(socket, wrappingKey);
this._keyPair = restored.pair;
this._serverBackupState = restored.status;
if (restored.status === 'present') this._serverBackupExists = true;
}
/* 3. No key anywhere generate ONLY if no backup exists on the server.
* If server backup exists but unwrap failed, do NOT generate new keys
* (that would overwrite the existing key and break other devices).
* Instead, leave E2E unavailable and let the user resolve. */
if (!this._keyPair && wrappingKey && !this._serverBackupExists) {
/* 3. No key anywhere generate ONLY if we affirmatively confirmed the
* server has no backup. On 'unknown' we bail out entirely: generating
* a fresh key here and uploading would clobber a potentially-good
* backup once the network comes back. A 5-min cooldown prevents
* flap-regenerate storms across reconnects. */
if (!this._keyPair && wrappingKey) {
if (this._serverBackupState !== 'none') {
this._ghostState = true;
console.warn('[E2E] Server state ' + this._serverBackupState + ' — refusing to generate keys (would risk clobber)');
this._ready = false;
return false;
}
if (!(await this._canAttemptGenerate())) {
this._ghostState = true;
console.warn('[E2E] Regenerate cooldown active — refusing to mint new keypair');
this._ready = false;
return false;
}
this._keyPair = await this._generate();
await this._saveLocal(this._keyPair);
await this._markGenerateAttempt();
this._freshlyGenerated = true;
console.log('[E2E] Generated new key pair (first-time setup)');
console.log('[E2E] Generated new key pair (first-time setup, server confirmed empty)');
}
/* 4. Auto-login without IndexedDB — E2E unavailable until real login */
@ -136,6 +170,15 @@ class HavenE2E {
}
}
/** True if local and server backups disagree on the public key. UI can prompt user to sync or reset. */
get divergent() { return !!this._divergent; }
/** True if init bailed out to protect a possibly-good server backup. UI should prompt for action. */
get ghostState() { return !!this._ghostState; }
/** Last observed server backup state: 'present' | 'none' | 'unknown'. */
get serverBackupState() { return this._serverBackupState || 'unknown'; }
/**
* Generate fresh keys, upload backup, publish to server.
* Old encrypted messages become permanently unreadable.
@ -354,14 +397,56 @@ class HavenE2E {
/* ─── Server communication ────────────────────────── */
_fetchBackup(socket) {
return new Promise(resolve => {
const t = setTimeout(() => resolve(null), 5000);
return this._fetchBackupWithState(socket).then(r => r.status === 'present' ? r.data : null);
}
/**
* Fetch encrypted key backup with explicit status tracking.
* Returns { status, data, serverPublicKey }:
* status = 'present' server returned a backup blob
* status = 'none' server confirmed there is no backup (safe to generate)
* status = 'unknown' request timed out or errored (do NOT treat as empty)
* Retries once before giving up, so transient network blips aren't treated as "no backup".
* This distinction is load-bearing: the previous 5s-timeout-returns-null design
* conflated "confirmed empty" with "unreachable", which could let the client
* overwrite a good server backup after flaky mobile reconnects.
*/
_fetchBackupWithState(socket) {
const TIMEOUT = 15000;
const attempt = () => new Promise(resolve => {
let done = false;
const t = setTimeout(() => {
if (done) return;
done = true;
resolve({ status: 'unknown', data: null, serverPublicKey: null });
}, TIMEOUT);
socket.once('encrypted-key-result', data => {
if (done) return;
done = true;
clearTimeout(t);
resolve(data && data.encryptedKey && data.salt ? data : null);
const hasBlob = data && data.encryptedKey && data.salt;
if (hasBlob) {
resolve({ status: 'present', data, serverPublicKey: data.publicKey || null });
} else if (data && (data.state === 'empty' || (data.state === undefined && data.hasPublicKey === false))) {
// Server-confirmed empty. Legacy servers (no `state` field) fall back to
// the hasPublicKey heuristic: if the user has no public key either, the
// account is truly fresh.
resolve({ status: 'none', data: null, serverPublicKey: null });
} else if (data && data.state === 'empty') {
resolve({ status: 'none', data: null, serverPublicKey: null });
} else {
// Legacy server returned null blob but has a public key — ambiguous.
// Treat as unknown to be safe; we'd rather retry than risk a clobber.
resolve({ status: 'unknown', data: null, serverPublicKey: null });
}
});
socket.emit('get-encrypted-key');
});
return attempt().then(first => {
if (first.status !== 'unknown') return first;
console.warn('[E2E] Backup fetch timed out, retrying once');
return attempt();
});
}
async _uploadBackup(socket, secret) {
@ -424,21 +509,69 @@ class HavenE2E {
/* ─── Server restore helper ───────────────────────── */
async _restoreFromServer(socket, secret) {
const backup = await this._fetchBackup(socket);
if (!backup) return null;
const r = await this._restoreFromServerWithState(socket, secret);
return r.pair;
}
/**
* Restore with status awareness. Returns { pair, status }:
* status = 'present' backup found (pair may still be null if unwrap failed)
* status = 'none' server confirmed empty; caller may generate fresh keys
* status = 'unknown' network issue; caller MUST NOT overwrite server state
*/
async _restoreFromServerWithState(socket, secret) {
const probe = await this._fetchBackupWithState(socket);
if (probe.status !== 'present') return { pair: null, status: probe.status };
this._serverBackupExists = true;
try {
const jwk = await this._unwrap(secret, backup.encryptedKey, backup.salt);
const jwk = await this._unwrap(secret, probe.data.encryptedKey, probe.data.salt);
const pair = await this._importPair(jwk);
await this._saveLocal(pair);
console.log('[E2E] Restored from server backup');
return pair;
return { pair, status: 'present' };
} catch {
console.warn('[E2E] Server backup unwrap failed — keys NOT auto-regenerated to protect other devices');
return null;
return { pair: null, status: 'present' };
}
}
/* ─── Regenerate cooldown ─────────────────────────── */
_cooldownGet() {
return new Promise(resolve => {
try {
const tx = this._db.transaction('keys', 'readonly');
const r = tx.objectStore('keys').get('last_generate_attempt');
tx.oncomplete = () => resolve(typeof r.result === 'number' ? r.result : 0);
tx.onerror = () => resolve(0);
} catch { resolve(0); }
});
}
_cooldownSet(val) {
return new Promise(resolve => {
try {
const tx = this._db.transaction('keys', 'readwrite');
const s = tx.objectStore('keys');
if (val === null) s.delete('last_generate_attempt');
else s.put(val, 'last_generate_attempt');
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch { resolve(); }
});
}
async _canAttemptGenerate() {
const last = await this._cooldownGet();
if (!last) return true;
return Date.now() - last >= 5 * 60 * 1000;
}
async _markGenerateAttempt() { await this._cooldownSet(Date.now()); }
/** Clear the regenerate cooldown (e.g. on explicit user-driven reset). */
async clearGenerateCooldown() { await this._cooldownSet(null); }
/**
* Sync keys from the server backup (clears local keys first).
* Used when another device changed the key and this device needs to catch up.

View file

@ -139,6 +139,7 @@ async switchChannel(code) {
this.socket.emit('get-channel-members', { code });
this.socket.emit('request-voice-users', { code });
this._clearReply();
this._closeThread();
// Auto-focus the message input for quick typing
const msgInput = document.getElementById('message-input');
@ -434,6 +435,14 @@ _initDmContextMenu() {
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
});
// Copy DM link
document.querySelector('[data-action="dm-copy-link"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
this._copyChannelLink(code);
});
// Delete DM
document.querySelector('[data-action="dm-delete"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
@ -776,8 +785,13 @@ _renderOrganizeList() {
let displayList = [...(this._organizeList || [])];
// Collect unique tags (including __untagged__ as a sortable entry)
const realTags = [...new Set(displayList.filter(c => c.category).map(c => c.category))];
// Collect unique tags (case-insensitive dedup, keep first-seen casing)
const _orgTagMap = new Map();
displayList.filter(c => c.category).forEach(c => {
const key = c.category.toLowerCase();
if (!_orgTagMap.has(key)) _orgTagMap.set(key, c.category);
});
const realTags = [..._orgTagMap.values()];
const hasUntagged = displayList.some(c => !c.category);
const hasTags = realTags.length > 0;
// Build the full ordered keys list: real tags + __untagged__ (if applicable)
@ -844,7 +858,8 @@ _renderOrganizeList() {
}
} else {
const tagSort = this._organizeTagSorts[key] || globalSort;
const tagItems = sortGroup(displayList.filter(c => c.category === key), tagSort);
const keyLower = key.toLowerCase();
const tagItems = sortGroup(displayList.filter(c => c.category && c.category.toLowerCase() === keyLower), tagSort);
grouped.push({ tag: key, items: tagItems, sort: tagSort });
}
}
@ -976,9 +991,9 @@ _getOrganizeVisualGroup(ch) {
const tagKey = ch.category || '__untagged__';
const effectiveSort = this._organizeTagSorts[tagKey] || globalSort;
// Collect channels in the same tag group
// Collect channels in the same tag group (case-insensitive)
const group = ch.category
? this._organizeList.filter(c => c.category === ch.category)
? this._organizeList.filter(c => c.category && c.category.toLowerCase() === ch.category.toLowerCase())
: this._organizeList.filter(c => !c.category);
// Sort by effective mode (mirrors _renderOrganizeList's sortGroup)
@ -1006,7 +1021,7 @@ _moveCategoryInOrder(direction) {
// Build full ordered keys (real tags + __untagged__) from channel data
const displayList = [...(this._organizeList || [])];
const realTags = [...new Set(displayList.filter(c => c.category).map(c => c.category))];
const realTags = [...new Map(displayList.filter(c => c.category).map(c => [c.category.toLowerCase(), c.category])).values()];
const hasUntagged = displayList.some(c => !c.category);
const allKeys = [...realTags];
if (hasUntagged) allKeys.push('__untagged__');
@ -1257,7 +1272,7 @@ _renderChannels() {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA !== tagB) {
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (catSort === 'manual') {
@ -1339,7 +1354,7 @@ _renderChannels() {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA !== tagB) {
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (serverCatSort === 'manual') {
@ -1513,10 +1528,14 @@ _renderChannels() {
if (dp) dp.style.flex = '1 1 0';
}
// ── Render channels grouped by category ──
// ── Render channels grouped by category (case-insensitive) ──
const categories = new Map();
const _catCanonical = new Map(); // lowercase -> first-seen casing
parentChannels.forEach(ch => {
const cat = ch.category || '';
const raw = ch.category || '';
const key = raw.toLowerCase();
if (!_catCanonical.has(key)) _catCanonical.set(key, raw);
const cat = _catCanonical.get(key);
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat).push(ch);
});
@ -1602,7 +1621,7 @@ _renderChannels() {
const subHasTags = subs.some(s => s.category);
let lastSubTag = undefined;
subs.forEach(sub => {
if (subHasTags && sub.category !== lastSubTag) {
if (subHasTags && (lastSubTag === undefined || (sub.category || '').toLowerCase() !== (lastSubTag || '').toLowerCase())) {
const tagName = sub.category || 'Untagged';
const tagKey = `haven_subtag_collapsed_${ch.code}_${tagName}`;
const isTagCollapsed = localStorage.getItem(tagKey) === 'true';

View file

@ -217,7 +217,9 @@ _setupNotifications() {
const replyVolume = document.getElementById('notif-reply-volume');
const replySound = document.getElementById('notif-reply-sound');
const sentSound = document.getElementById('notif-sent-sound');
const joinVolume = document.getElementById('notif-join-volume');
const joinSound = document.getElementById('notif-join-sound');
const leaveVolume = document.getElementById('notif-leave-volume');
const leaveSound = document.getElementById('notif-leave-sound');
toggle.checked = this.notifications.enabled;
@ -228,7 +230,9 @@ _setupNotifications() {
mentionSound.value = this.notifications.sounds.mention;
if (replyVolume) replyVolume.value = this.notifications.replyVolume * 100;
if (replySound) replySound.value = this.notifications.sounds.reply;
if (joinVolume) joinVolume.value = this.notifications.joinVolume * 100;
if (joinSound) joinSound.value = this.notifications.sounds.join;
if (leaveVolume) leaveVolume.value = this.notifications.leaveVolume * 100;
if (leaveSound) leaveSound.value = this.notifications.sounds.leave;
// Per-type toggles
@ -281,6 +285,12 @@ _setupNotifications() {
});
}
if (joinVolume) {
joinVolume.addEventListener('input', () => {
this.notifications.setJoinVolume(joinVolume.value / 100);
});
}
if (joinSound) {
joinSound.addEventListener('change', () => {
this.notifications.setSound('join', joinSound.value);
@ -288,6 +298,12 @@ _setupNotifications() {
});
}
if (leaveVolume) {
leaveVolume.addEventListener('input', () => {
this.notifications.setLeaveVolume(leaveVolume.value / 100);
});
}
if (leaveSound) {
leaveSound.addEventListener('change', () => {
this.notifications.setSound('leave', leaveSound.value);
@ -332,6 +348,15 @@ _setupNotifications() {
});
}
// Up arrow edits last message (on by default)
const upArrowEditToggle = document.getElementById('up-arrow-edit');
if (upArrowEditToggle) {
upArrowEditToggle.checked = localStorage.getItem('haven_up_arrow_edit') !== 'false';
upArrowEditToggle.addEventListener('change', () => {
localStorage.setItem('haven_up_arrow_edit', String(upArrowEditToggle.checked));
});
}
// Show status bar (opt-in — hidden by default, but Desktop always shows its own footer)
const showStatusBarToggle = document.getElementById('show-status-bar');
const statusBarToggleTab = document.getElementById('status-bar-toggle');

View file

@ -1596,6 +1596,140 @@ _setupRoleDisplayPicker() {
});
},
// ── Toolbar Icon Style Picker ──
_setupToolbarIconPicker() {
const picker = document.getElementById('toolbar-icon-picker');
const slotsInput = document.getElementById('toolbar-visible-slots');
const slotsValue = document.getElementById('toolbar-visible-slots-value');
const orderList = document.getElementById('toolbar-order-list');
const resetBtn = document.getElementById('toolbar-order-reset-btn');
if (!picker) return;
const defaultOrder = ['react', 'reply', 'quote', 'thread', 'pin', 'archive', 'edit', 'delete'];
const actionLabels = {
react: 'React',
reply: 'Reply',
quote: 'Quote',
thread: 'Thread',
pin: 'Pin / Unpin',
archive: 'Protect / Unprotect',
edit: 'Edit',
delete: 'Delete'
};
const normalizeOrder = (value) => {
const arr = Array.isArray(value) ? value : [];
const clean = [];
arr.forEach((k) => {
if (defaultOrder.includes(k) && !clean.includes(k)) clean.push(k);
});
defaultOrder.forEach((k) => {
if (!clean.includes(k)) clean.push(k);
});
return clean;
};
const refreshCurrentMessages = () => {
if (this.currentChannel && this.socket?.connected) {
this.socket.emit('get-messages', { code: this.currentChannel });
}
};
const savedMode = localStorage.getItem('haven-toolbar-icons') || 'mono';
const normalizedMode = savedMode === 'color' ? 'emoji' : savedMode;
document.documentElement.dataset.toolbaricons = normalizedMode;
picker.querySelectorAll('[data-toolbaricons]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.toolbaricons === normalizedMode);
});
let savedSlots = parseInt(localStorage.getItem('haven-toolbar-visible-slots') || '3', 10);
if (!Number.isFinite(savedSlots)) savedSlots = 3;
savedSlots = Math.max(1, Math.min(7, savedSlots));
localStorage.setItem('haven-toolbar-visible-slots', String(savedSlots));
if (slotsInput) slotsInput.value = String(savedSlots);
if (slotsValue) slotsValue.textContent = String(savedSlots);
let savedOrder;
try {
savedOrder = JSON.parse(localStorage.getItem('haven-toolbar-order') || '[]');
} catch {
savedOrder = [];
}
let currentOrder = normalizeOrder(savedOrder);
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
const renderOrderList = () => {
if (!orderList) return;
orderList.innerHTML = '';
currentOrder.forEach((key, index) => {
const row = document.createElement('div');
row.className = 'toolbar-order-item';
row.innerHTML = `
<span class="toolbar-order-item-label">${actionLabels[key] || key}</span>
<div class="toolbar-order-item-controls">
<button type="button" class="toolbar-order-move" data-dir="up" data-key="${key}" ${index === 0 ? 'disabled' : ''} title="Move up"></button>
<button type="button" class="toolbar-order-move" data-dir="down" data-key="${key}" ${index === currentOrder.length - 1 ? 'disabled' : ''} title="Move down"></button>
</div>
`;
orderList.appendChild(row);
});
};
renderOrderList();
picker.addEventListener('click', (e) => {
const btn = e.target.closest('[data-toolbaricons]');
if (!btn) return;
const mode = btn.dataset.toolbaricons;
document.documentElement.dataset.toolbaricons = mode;
localStorage.setItem('haven-toolbar-icons', mode);
picker.querySelectorAll('[data-toolbaricons]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
refreshCurrentMessages();
});
if (slotsInput) {
slotsInput.addEventListener('input', () => {
if (slotsValue) slotsValue.textContent = slotsInput.value;
});
slotsInput.addEventListener('change', () => {
const value = Math.max(1, Math.min(7, parseInt(slotsInput.value || '3', 10) || 3));
localStorage.setItem('haven-toolbar-visible-slots', String(value));
if (slotsValue) slotsValue.textContent = String(value);
refreshCurrentMessages();
});
}
if (orderList) {
orderList.addEventListener('click', (e) => {
const btn = e.target.closest('.toolbar-order-move');
if (!btn) return;
const key = btn.dataset.key;
const dir = btn.dataset.dir;
const idx = currentOrder.indexOf(key);
if (idx < 0) return;
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (swapWith < 0 || swapWith >= currentOrder.length) return;
const next = currentOrder.slice();
[next[idx], next[swapWith]] = [next[swapWith], next[idx]];
currentOrder = next;
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
renderOrderList();
refreshCurrentMessages();
});
}
if (resetBtn) {
resetBtn.addEventListener('click', () => {
currentOrder = defaultOrder.slice();
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
renderOrderList();
refreshCurrentMessages();
});
}
},
// ── Image Lightbox ──
_setupLightbox() {
@ -1790,26 +1924,31 @@ _showImageContextMenu(e, src) {
a.remove();
} else if (action === 'copy') {
try {
const resp = await fetch(src);
const blob = await resp.blob();
// ClipboardItem only reliably supports image/png — convert if needed
let pngBlob = blob;
if (blob.type !== 'image/png') {
const img = new Image();
img.crossOrigin = 'anonymous';
const loaded = new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
img.src = URL.createObjectURL(blob);
await loaded;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(img.src);
pngBlob = await new Promise(r => canvas.toBlob(r, 'image/png'));
// Always convert via canvas to guarantee a valid image/png blob
const img = new Image();
img.crossOrigin = 'anonymous';
const loaded = new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
img.src = src;
await loaded;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
const pngBlob = await new Promise((res, rej) => {
canvas.toBlob(b => b ? res(b) : rej(new Error('canvas.toBlob returned null')), 'image/png');
});
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard && navigator.clipboard.write) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} else {
// Fallback: copy data URL as text
const reader = new FileReader();
const dataUrl = await new Promise(r => { reader.onload = () => r(reader.result); reader.readAsDataURL(pngBlob); });
await navigator.clipboard.writeText(dataUrl);
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
this._showToast('Image copied to clipboard', 'success');
} catch {
} catch (err) {
console.error('[Haven] Copy image failed:', err);
this._showToast('Failed to copy image', 'error');
}
} else if (action === 'open') {

View file

@ -181,7 +181,7 @@ _jumpToMessage(msgId) {
this.socket.emit('get-messages', { code: this.currentChannel, around: msgId });
},
_renderMessages(messages) {
_renderMessages(messages, lastReadMessageId) {
const container = document.getElementById('messages');
container.innerHTML = '';
// Only render the last MAX_DOM_MESSAGES to prevent OOM on large histories
@ -189,8 +189,30 @@ _renderMessages(messages) {
const start = messages.length > MAX_DOM_MESSAGES ? messages.length - MAX_DOM_MESSAGES : 0;
// Use DocumentFragment to batch all DOM inserts into a single reflow
const frag = document.createDocumentFragment();
// Determine where to insert the "NEW MESSAGES" divider.
// Only show it when there are actually unread messages and the last message
// isn't already "read" (i.e. the user isn't fully caught up).
let newMsgDividerInserted = false;
const showDivider = lastReadMessageId && messages.length > 0
&& messages[messages.length - 1].id > lastReadMessageId
// Don't show divider if ALL messages are unread (nothing before the line)
&& messages[start]?.id <= lastReadMessageId;
for (let i = start; i < messages.length; i++) {
const prevMsg = i > start ? messages[i - 1] : null;
// Insert "NEW MESSAGES" divider before the first unread message
if (showDivider && !newMsgDividerInserted && messages[i].id > lastReadMessageId
&& messages[i].user_id !== this.user?.id) {
const divider = document.createElement('div');
divider.className = 'new-messages-divider';
divider.id = 'new-messages-divider';
divider.innerHTML = '<span>NEW MESSAGES</span>';
frag.appendChild(divider);
newMsgDividerInserted = true;
}
frag.appendChild(this._createMessageEl(messages[i], prevMsg));
}
container.appendChild(frag);
@ -210,6 +232,19 @@ _renderMessages(messages) {
scrollToTarget();
requestAnimationFrame(scrollToTarget);
setTimeout(scrollToTarget, 300);
} else if (newMsgDividerInserted) {
// Scroll to the "NEW MESSAGES" divider so the user sees where they left off
this._coupledToBottom = false;
const scrollToDivider = () => {
const divider = document.getElementById('new-messages-divider');
if (divider) divider.scrollIntoView({ block: 'start' });
};
scrollToDivider();
requestAnimationFrame(scrollToDivider);
setTimeout(scrollToDivider, 300);
// Show jump-to-bottom button since we're not at the bottom
const jumpBtn = document.getElementById('jump-to-bottom');
if (jumpBtn) jumpBtn.classList.add('visible');
} else {
this._scrollToBottom(true);
// Re-scroll after images load, but only if user hasn't scrolled away
@ -496,33 +531,87 @@ _createMessageEl(msg, prevMsg) {
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
const pollHtml = msg.poll ? this._renderPollWidget(msg.id, msg.poll) : '';
const threadHtml = msg.thread ? this._renderThreadPreview(msg.id, msg.thread) : '';
const editedHtml = msg.edited_at ? `<span class="edited-tag" title="${t('app.messages.edited_at', { date: new Date(msg.edited_at).toLocaleString() })}">${t('app.messages.edited')}</span>` : '';
const pinnedTag = msg.pinned ? `<span class="pinned-tag" title="${t('app.messages.pinned')}">📌</span>` : '';
const archivedTag = msg.is_archived ? `<span class="archived-tag" title="${t('app.messages.protected')}">🛡️</span>` : '';
const e2eTag = msg._e2e ? `<span class="e2e-tag" title="${t('app.messages.e2e_encrypted')}">🔒</span>` : '';
// Build toolbar with context-aware buttons
let toolbarBtns = `<button data-action="react" title="${t('msg_toolbar.react')}">😀</button><button data-action="reply" title="${t('msg_toolbar.reply')}">↩️</button>`;
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
const iReact = iconPair('😀', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iThread = iconPair('🧵', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 9h8" stroke-width="1.8" stroke-linecap="round"></path><path d="M8 13h6" stroke-width="1.8" stroke-linecap="round"></path><path d="M6 6h12a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-8l-4 3v-3H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iPin = iconPair('📌', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4h8l-2 5v4l2 2H8l2-2V9L8 4z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M12 15v5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iArchive = iconPair('🛡️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v11H4z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M9 11h6" stroke-width="1.8" stroke-linecap="round"></path><path d="M3 7l2-3h14l2 3" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iLink = iconPair('🔗', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
const toolbarActions = [
{ key: 'react', html: `<button data-action="react" title="${t('msg_toolbar.react')}">${iReact}</button>` },
{ key: 'reply', html: `<button data-action="reply" title="${t('msg_toolbar.reply')}">${iReply}</button>` },
{ key: 'quote', html: `<button data-action="quote" title="${t('msg_toolbar.quote')}">${iQuote}</button>` },
{ key: 'thread', html: `<button data-action="thread" title="Thread">${iThread}</button>` },
{ key: 'copy-link', html: `<button data-action="copy-link" title="${t('msg_toolbar.copy_link') || 'Copy link to message'}">${iLink}</button>` }
];
const canPin = this.user.isAdmin || this._canModerate();
const canArchive = this.user.isAdmin || this._hasPerm('archive_messages');
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
if (canPin) {
toolbarBtns += msg.pinned
? `<button data-action="unpin" title="${t('msg_toolbar.unpin')}">📌</button>`
: `<button data-action="pin" title="${t('msg_toolbar.pin')}">📌</button>`;
toolbarActions.push({
key: 'pin',
html: msg.pinned
? `<button data-action="unpin" title="${t('msg_toolbar.unpin')}">${iPin}</button>`
: `<button data-action="pin" title="${t('msg_toolbar.pin')}">${iPin}</button>`
});
}
if (canArchive) {
toolbarBtns += msg.is_archived
? `<button data-action="unarchive" title="${t('app.messages.unprotect_btn')}">🛡️</button>`
: `<button data-action="archive" title="${t('app.messages.protect_btn')}">🛡️</button>`;
toolbarActions.push({
key: 'archive',
html: msg.is_archived
? `<button data-action="unarchive" title="${t('app.messages.unprotect_btn')}">${iArchive}</button>`
: `<button data-action="archive" title="${t('app.messages.protect_btn')}">${iArchive}</button>`
});
}
if (msg.user_id === this.user.id) {
toolbarBtns += `<button data-action="edit" title="${t('msg_toolbar.edit')}">✏️</button>`;
toolbarActions.push({ key: 'edit', html: `<button data-action="edit" title="${t('msg_toolbar.edit')}">${iEdit}</button>` });
}
if (canDelete) {
toolbarBtns += `<button data-action="delete" title="${t('msg_toolbar.delete')}">🗑️</button>`;
toolbarActions.push({ key: 'delete', html: `<button data-action="delete" title="${t('msg_toolbar.delete')}">${iDelete}</button>` });
}
const toolbarHtml = `<div class="msg-toolbar">${toolbarBtns}</div>`;
const defaultToolbarOrder = ['react', 'reply', 'quote', 'thread', 'copy-link', 'pin', 'archive', 'edit', 'delete'];
let savedToolbarOrder = [];
try {
savedToolbarOrder = JSON.parse(localStorage.getItem('haven-toolbar-order') || '[]');
} catch {
savedToolbarOrder = [];
}
const normalizedOrder = [];
savedToolbarOrder.forEach((key) => {
if (defaultToolbarOrder.includes(key) && !normalizedOrder.includes(key)) normalizedOrder.push(key);
});
defaultToolbarOrder.forEach((key) => {
if (!normalizedOrder.includes(key)) normalizedOrder.push(key);
});
const orderRank = new Map(normalizedOrder.map((key, index) => [key, index]));
toolbarActions.sort((a, b) => (orderRank.get(a.key) ?? 999) - (orderRank.get(b.key) ?? 999));
let visibleSlots = parseInt(localStorage.getItem('haven-toolbar-visible-slots') || '3', 10);
if (!Number.isFinite(visibleSlots)) visibleSlots = 3;
visibleSlots = Math.max(1, Math.min(7, visibleSlots));
const visibleActions = toolbarActions.slice(0, visibleSlots);
const overflowActions = toolbarActions.slice(visibleSlots);
const coreToolbarBtns = visibleActions.map(a => a.html).join('');
const overflowToolbarBtns = overflowActions.map(a => a.html).join('');
const moreMenuHtml = overflowActions.length
? `<div class="msg-toolbar-more"><button class="msg-toolbar-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="msg-toolbar-overflow">${overflowToolbarBtns}</div></div>`
: '';
const toolbarHtml = `<div class="msg-toolbar"><div class="msg-toolbar-group">${coreToolbarBtns}</div>${moreMenuHtml}</div>`;
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
if (isCompact) {
@ -544,6 +633,7 @@ _createMessageEl(msg, prevMsg) {
<div class="message-content">${pinnedTag}${archivedTag}${this._formatContent(msg.content)}${editedHtml}</div>
${pollHtml}
${reactionsHtml}
${threadHtml}
</div>
${e2eTag}
${toolbarHtml}
@ -630,6 +720,7 @@ _createMessageEl(msg, prevMsg) {
<div class="message-content">${this._formatContent(msg.content)}${editedHtml}</div>
${pollHtml}
${reactionsHtml}
${threadHtml}
</div>
${toolbarHtml}
<button class="msg-dots-btn" aria-label="${t('app.actions.message_actions')}"></button>
@ -1035,6 +1126,7 @@ _enterMoveSelectionMode() {
_exitMoveSelectionMode() {
this._moveSelectionActive = false;
this._moveSelectedIds.clear();
this._lastMoveSelectedEl = null;
document.body.classList.remove('move-selection-mode');
const toolbar = document.getElementById('move-msg-toolbar');
if (toolbar) toolbar.style.display = 'none';

View file

@ -92,6 +92,9 @@ _initDesktopAppBanner() {
// Don't show if already in the desktop app
if (window.havenDesktop || navigator.userAgent.includes('Electron')) return;
// Don't show on mobile / tablet — desktop app isn't relevant there
if (/Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent)) return;
// ── Top-bar banner ──
const bannerDismissed = localStorage.getItem('haven_desktop_banner_dismissed');
if (!bannerDismissed) {
@ -453,9 +456,17 @@ async _initE2E() {
const ok = await this.e2e.init(this.socket, wrappingKey);
// Keep wrapping key in memory for cross-device sync (conflict resolution).
// Clear from sessionStorage but retain privately for backup restoration.
// Also persist to localStorage so server list sync works across page reloads.
if (wrappingKey) {
this._e2eWrappingKey = wrappingKey;
sessionStorage.removeItem('haven_e2e_wrap');
try { localStorage.setItem('haven_sync_key', wrappingKey); } catch { /* private mode */ }
} else {
// On auto-login (no password), recover the sync key from localStorage
try {
const savedKey = localStorage.getItem('haven_sync_key');
if (savedKey) this._e2eWrappingKey = savedKey;
} catch { /* ignore */ }
}
if (ok) {
await this._e2eSetupListeners();
@ -481,6 +492,38 @@ async _initE2E() {
if (syncKey && this.serverManager) {
await this.serverManager.syncWithServer(this.token, syncKey);
this._renderServerBar();
this._pushServersToDesktopHistory();
// Re-sync periodically (every 5 min) so cross-device changes propagate
// without requiring a full page reload or re-login
if (!this._serverSyncInterval) {
this._serverSyncInterval = setInterval(async () => {
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (key && this.serverManager && this.token) {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch { /* silent — best-effort background sync */ }
}
}, 5 * 60 * 1000);
}
// Also sync when the tab becomes visible (user switching back from another server)
if (!this._serverSyncVisibility) {
this._serverSyncVisibility = true;
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return;
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (key && this.serverManager && this.token) {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch { /* silent */ }
}
});
}
}
} catch (err) {
console.warn('[ServerSync] Post-login sync failed:', err.message);

View file

@ -232,6 +232,7 @@ _setupSocketListeners() {
if (err.message === 'Invalid token' || err.message === 'Authentication required' || err.message === 'Session expired') {
localStorage.removeItem('haven_token');
localStorage.removeItem('haven_user');
localStorage.removeItem('haven_sync_key');
window.location.href = '/';
}
this._setLed('connection-led', 'danger');
@ -295,11 +296,46 @@ _setupSocketListeners() {
if (inviteCode && !this._inviteHandled) {
this._inviteHandled = true;
this.socket.emit('join-channel', { code: inviteCode });
sessionStorage.removeItem('haven_pending_invite');
// Clean up the URL
const cleanUrl = window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
}
// Channel / message deep link (?channel=CODE[&message=ID])
const linkChannel = urlParams.get('channel');
const linkMessage = urlParams.get('message');
if (linkChannel && !this._channelLinkHandled) {
this._channelLinkHandled = true;
sessionStorage.removeItem('haven_pending_channel');
sessionStorage.removeItem('haven_pending_message');
const known = (channels || []).some(c => c.code === linkChannel);
const go = () => {
this.switchChannel(linkChannel);
if (linkMessage) {
const msgId = parseInt(linkMessage, 10);
if (!isNaN(msgId)) {
// Wait briefly for messages to load before jumping
setTimeout(() => this._jumpToMessage(msgId), 600);
}
}
};
if (known) {
go();
} else {
// Try to join the channel by code first; if that succeeds the channel
// list will update and we can switch. If it fails, fall through silently.
this.socket.emit('join-channel', { code: linkChannel }, (res) => {
if (res && res.error) {
this._showToast?.(t('toasts.channel_link_unavailable') || 'Channel not available on this server', 'error');
} else {
setTimeout(go, 200);
}
});
}
window.history.replaceState({}, '', window.location.pathname);
}
// Re-evaluate input area visibility for the current channel (read-only, text/media toggles may have changed)
if (this.currentChannel) {
const curCh = this.channels.find(c => c.code === this.currentChannel);
@ -416,7 +452,7 @@ _setupSocketListeners() {
} else {
this._noMoreHistory = true;
}
this._renderMessages(data.messages);
this._renderMessages(data.messages, data.lastReadMessageId);
}
// Re-append any pending E2E notice (survives message re-render after key change)
@ -727,6 +763,45 @@ _setupSocketListeners() {
}
});
// ── Threads ───────────────────────────────────────
this.socket.on('thread-messages', (data) => {
if (data.parentUsername) {
this._setThreadParentHeader({
username: data.parentUsername,
avatar: data.parentAvatar || null,
avatarShape: data.parentAvatarShape || 'circle'
});
}
// Update parent preview from server (authoritative source)
if (data.parentContent) {
const preview = document.getElementById('thread-parent-preview');
if (preview) {
const text = data.parentContent.length > 120 ? data.parentContent.substring(0, 120) + '…' : data.parentContent;
preview.textContent = text;
}
}
const container = document.getElementById('thread-messages');
if (!container) return;
container.innerHTML = '';
if (data.messages) {
data.messages.forEach(msg => this._appendThreadMessage(msg));
}
});
this.socket.on('new-thread-message', (data) => {
if (data.channelCode !== this.currentChannel) return;
// If this thread is open, append the message
if (this._activeThreadParent === data.parentId) {
this._appendThreadMessage(data.message);
}
});
this.socket.on('thread-updated', (data) => {
if (data.channelCode !== this.currentChannel) return;
this._updateThreadPreview(data.parentId, data.thread);
});
// ── Polls ─────────────────────────────────────────
this.socket.on('poll-updated', (data) => {
if (data.channelCode === this.currentChannel) {
@ -959,7 +1034,7 @@ _setupSocketListeners() {
if (data.channelCode === this.currentChannel) {
const msgEl = document.querySelector(`[data-msg-id="${data.messageId}"]`);
if (!msgEl) return;
const contentEl = msgEl.querySelector('.message-content');
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
if (contentEl) {
// E2E: decrypt if needed
let displayContent = data.content;
@ -1007,6 +1082,13 @@ _setupSocketListeners() {
}
});
// ── Bot soundboard trigger ───────────────────────
this.socket.on('play-sound', (data) => {
if (data.channelCode === this.currentChannel && data.soundUrl) {
this._playSoundFile(data.soundUrl);
}
});
// ── Messages moved (source channel) ──────────────
this.socket.on('messages-moved', (data) => {
if (data.channelCode === this.currentChannel) {

View file

@ -67,6 +67,20 @@ _setupUI() {
e.preventDefault();
this._sendMessage();
}
// Up arrow on empty input → edit last own message (toggleable)
if (e.key === 'ArrowUp' && !msgInput.value && localStorage.getItem('haven_up_arrow_edit') !== 'false') {
const msgs = document.getElementById('messages');
const allMsgs = [...msgs.querySelectorAll('.message, .message-compact')];
for (let i = allMsgs.length - 1; i >= 0; i--) {
const el = allMsgs[i];
if (parseInt(el.dataset.userId) === this.user.id && !el.classList.contains('editing')) {
e.preventDefault();
this._startEditMessage(el, parseInt(el.dataset.msgId));
break;
}
}
}
});
msgInput.addEventListener('input', () => {
@ -188,6 +202,13 @@ _setupUI() {
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
this._renderChannels();
});
// Copy channel link from context menu
document.querySelector('#channel-ctx-menu [data-action="copy-channel-link"]')?.addEventListener('click', () => {
const code = this._ctxMenuChannel;
if (!code) return;
this._closeChannelCtxMenu();
this._copyChannelLink(code);
});
// Join voice from context menu
document.querySelector('[data-action="join-voice"]')?.addEventListener('click', () => {
const code = this._ctxMenuChannel;
@ -1148,6 +1169,14 @@ _setupUI() {
this.switchChannel(channelCode);
setTimeout(() => this._joinVoice(), 500);
};
// Wire up voice-kicked (joined from another client/tab)
this.voice.onVoiceKicked = (channelCode, reason) => {
this._showToast(reason || 'Voice disconnected — joined from another client', 'info');
this._updateVoiceButtons(false);
this._updateVoiceStatus(false);
this._updateVoiceBar();
};
// Re-render voice user list when webcam status changes
this.voice.onWebcamStatusChange = () => {
if (this._lastVoiceUsers) this._renderVoiceUsers(this._lastVoiceUsers);
@ -1334,6 +1363,7 @@ _setupUI() {
if (this.voice && this.voice.inVoice) this.voice.leave();
localStorage.removeItem('haven_token');
localStorage.removeItem('haven_user');
localStorage.removeItem('haven_sync_key');
window.location.href = '/';
});
@ -1452,6 +1482,175 @@ _setupUI() {
this._jumpToMessage(parseInt(replyMsgId, 10));
});
// Thread preview click — open thread panel
document.getElementById('messages').addEventListener('click', (e) => {
const preview = e.target.closest('.thread-preview');
if (!preview) return;
const parentId = parseInt(preview.dataset.threadParent);
if (parentId) this._openThread(parentId);
});
// Thread panel — close, send
const threadCloseBtn = document.getElementById('thread-panel-close');
if (threadCloseBtn) threadCloseBtn.addEventListener('click', () => this._closeThread());
const threadPipBtn = document.getElementById('thread-panel-pip');
if (threadPipBtn) threadPipBtn.addEventListener('click', () => this._toggleThreadPiP());
const threadSendBtn = document.getElementById('thread-send-btn');
if (threadSendBtn) threadSendBtn.addEventListener('click', () => this._sendThreadMessage());
const threadInput = document.getElementById('thread-input');
if (threadInput) {
threadInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this._sendThreadMessage();
}
});
}
const threadReplyCloseBtn = document.getElementById('thread-reply-close-btn');
if (threadReplyCloseBtn) threadReplyCloseBtn.addEventListener('click', () => this._clearThreadReply());
// Thread panel width resize (drag left edge)
const threadPanel = document.getElementById('thread-panel');
const threadResizer = document.getElementById('thread-panel-resizer');
if (threadPanel) {
const savedWidth = parseInt(localStorage.getItem('haven_thread_panel_width') || '', 10);
if (Number.isFinite(savedWidth) && savedWidth >= 300 && savedWidth <= 920) {
threadPanel.style.width = `${savedWidth}px`;
}
}
if (threadPanel && threadResizer) {
let resizing = false;
const clampWidth = (w) => {
const min = 300;
const max = Math.min(920, window.innerWidth - 220);
return Math.max(min, Math.min(max, w));
};
const onMove = (e) => {
if (!resizing || threadPanel.classList.contains('pip')) return;
const width = clampWidth(window.innerWidth - e.clientX);
threadPanel.style.width = `${width}px`;
};
const onUp = () => {
if (!resizing) return;
resizing = false;
document.body.classList.remove('resizing-thread-panel');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const current = parseInt(threadPanel.style.width || '', 10);
if (Number.isFinite(current)) {
localStorage.setItem('haven_thread_panel_width', String(clampWidth(current)));
}
};
threadResizer.addEventListener('mousedown', (e) => {
if (threadPanel.classList.contains('pip')) return;
resizing = true;
e.preventDefault();
document.body.classList.add('resizing-thread-panel');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
window.addEventListener('resize', () => {
if (threadPanel.classList.contains('pip')) return;
const current = parseInt(threadPanel.style.width || '', 10);
if (!Number.isFinite(current)) return;
const width = clampWidth(current);
if (width !== current) {
threadPanel.style.width = `${width}px`;
localStorage.setItem('haven_thread_panel_width', String(width));
}
});
}
// Thread panel PiP drag (drag by header)
if (threadPanel) {
const threadHeaderTop = threadPanel.querySelector('.thread-panel-header-top');
let draggingPiP = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
const footerOffset = () => {
const raw = getComputedStyle(document.body).getPropertyValue('--thread-footer-offset');
const v = parseInt(raw, 10);
return Number.isFinite(v) ? v : 0;
};
const clampPiPRect = (left, top, width, height) => {
const maxLeft = Math.max(0, window.innerWidth - width);
const maxTop = Math.max(0, window.innerHeight - footerOffset() - height);
return {
left: Math.max(0, Math.min(maxLeft, left)),
top: Math.max(0, Math.min(maxTop, top))
};
};
const savePiPRect = () => {
if (!threadPanel.classList.contains('pip')) return;
const r = threadPanel.getBoundingClientRect();
const rect = {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.round(r.width),
height: Math.round(r.height)
};
localStorage.setItem('haven_thread_panel_pip_rect', JSON.stringify(rect));
};
const onPiPMove = (e) => {
if (!draggingPiP || !threadPanel.classList.contains('pip')) return;
const r = threadPanel.getBoundingClientRect();
const rawLeft = e.clientX - dragOffsetX;
const rawTop = e.clientY - dragOffsetY;
const pos = clampPiPRect(rawLeft, rawTop, r.width, r.height);
threadPanel.style.left = `${pos.left}px`;
threadPanel.style.top = `${pos.top}px`;
threadPanel.style.right = 'auto';
threadPanel.style.bottom = 'auto';
};
const onPiPUp = () => {
if (!draggingPiP) return;
draggingPiP = false;
document.removeEventListener('mousemove', onPiPMove);
document.removeEventListener('mouseup', onPiPUp);
savePiPRect();
};
if (threadHeaderTop) {
threadHeaderTop.addEventListener('mousedown', (e) => {
if (!threadPanel.classList.contains('pip')) return;
if (e.target.closest('button, input, textarea, a')) return;
const r = threadPanel.getBoundingClientRect();
draggingPiP = true;
dragOffsetX = e.clientX - r.left;
dragOffsetY = e.clientY - r.top;
threadPanel.style.right = 'auto';
threadPanel.style.bottom = 'auto';
e.preventDefault();
document.addEventListener('mousemove', onPiPMove);
document.addEventListener('mouseup', onPiPUp);
});
}
if (window.ResizeObserver) {
const observer = new ResizeObserver(() => {
if (!threadPanel.classList.contains('pip')) return;
clearTimeout(this._threadPiPSaveTimer);
this._threadPiPSaveTimer = setTimeout(() => {
const r = threadPanel.getBoundingClientRect();
const pos = clampPiPRect(r.left, r.top, r.width, r.height);
threadPanel.style.left = `${pos.left}px`;
threadPanel.style.top = `${pos.top}px`;
savePiPRect();
}, 80);
});
observer.observe(threadPanel);
}
}
// Emoji picker toggle
document.getElementById('emoji-btn').addEventListener('click', () => {
this._toggleEmojiPicker();
@ -1472,7 +1671,7 @@ _setupUI() {
this._clearReply();
});
// Messages container — move-selection mode intercept
// Messages container — move-selection mode intercept (supports Shift+click range)
document.getElementById('messages').addEventListener('click', (e) => {
if (!this._moveSelectionActive) return;
// Don't intercept toolbar button clicks
@ -1481,7 +1680,30 @@ _setupUI() {
if (msgEl) {
e.preventDefault();
e.stopPropagation();
this._toggleMoveSelect(msgEl);
if (e.shiftKey && this._lastMoveSelectedEl) {
// Shift+click: select all messages between last selected and this one
const container = document.getElementById('messages');
const allMsgs = Array.from(container.querySelectorAll('.message, .message-compact'));
const lastIdx = allMsgs.indexOf(this._lastMoveSelectedEl);
const curIdx = allMsgs.indexOf(msgEl);
if (lastIdx !== -1 && curIdx !== -1) {
const start = Math.min(lastIdx, curIdx);
const end = Math.max(lastIdx, curIdx);
for (let i = start; i <= end; i++) {
const id = parseInt(allMsgs[i].dataset.msgId);
if (id && !this._moveSelectedIds.has(id)) {
if (this._moveSelectedIds.size >= 200) break;
this._moveSelectedIds.add(id);
allMsgs[i].classList.add('move-selected');
}
}
this._updateMoveCount();
}
} else {
this._toggleMoveSelect(msgEl);
this._lastMoveSelectedEl = msgEl;
}
}
}, true); // capture phase so it fires before the toolbar action handler
@ -1501,6 +1723,10 @@ _setupUI() {
this._showReactionPicker(msgEl, msgId);
} else if (action === 'reply') {
this._setReply(msgEl, msgId);
} else if (action === 'thread') {
this._openThread(msgId);
} else if (action === 'quote') {
this._quoteMessage(msgEl);
} else if (action === 'edit') {
this._startEditMessage(msgEl, msgId);
} else if (action === 'delete') {
@ -1515,6 +1741,8 @@ _setupUI() {
this.socket.emit('archive-message', { messageId: msgId });
} else if (action === 'unarchive') {
this.socket.emit('unarchive-message', { messageId: msgId });
} else if (action === 'copy-link') {
this._copyChannelLink(this.currentChannel, msgId);
}
});
@ -1522,6 +1750,7 @@ _setupUI() {
document.getElementById('messages').addEventListener('click', (e) => {
const badge = e.target.closest('.reaction-badge');
if (!badge) return;
this._hideReactionPopout();
const msgEl = badge.closest('.message, .message-compact');
if (!msgEl) return;
const msgId = parseInt(msgEl.dataset.msgId);
@ -1534,6 +1763,152 @@ _setupUI() {
}
});
// Thread panel reactions: open picker + toggle reaction on badges
const threadMessages = document.getElementById('thread-messages');
if (threadMessages) {
threadMessages.addEventListener('click', (e) => {
const threadActionBtn = e.target.closest('[data-thread-action]');
if (threadActionBtn) {
const msgEl = threadActionBtn.closest('.thread-message');
if (!msgEl) return;
const msgId = parseInt(msgEl.dataset.msgId, 10);
if (!msgId) return;
e.preventDefault();
e.stopPropagation();
const action = threadActionBtn.dataset.threadAction;
if (action === 'react') {
this._showReactionPicker(msgEl, msgId);
} else if (action === 'reply') {
this._setThreadReply(msgEl, msgId);
} else if (action === 'quote') {
this._quoteThreadMessage(msgEl);
} else if (action === 'edit') {
this._startEditMessage(msgEl, msgId);
} else if (action === 'delete') {
if (confirm(t('confirm.delete_message'))) {
this.socket.emit('delete-message', { messageId: msgId });
}
}
return;
}
const banner = e.target.closest('.reply-banner');
if (banner) {
const replyMsgId = parseInt(banner.dataset.replyMsgId || '', 10);
if (!replyMsgId) return;
const target = threadMessages.querySelector(`[data-msg-id="${replyMsgId}"]`);
if (target) {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.classList.add('thread-highlight');
setTimeout(() => target.classList.remove('thread-highlight'), 1200);
}
return;
}
const badge = e.target.closest('.reaction-badge');
if (!badge) return;
this._hideReactionPopout();
const msgEl = badge.closest('.thread-message');
if (!msgEl) return;
const msgId = parseInt(msgEl.dataset.msgId, 10);
const emoji = badge.dataset.emoji;
const hasOwn = badge.classList.contains('own');
if (!msgId || !emoji) return;
if (hasOwn) {
this.socket.emit('remove-reaction', { messageId: msgId, emoji });
} else {
this.socket.emit('add-reaction', { messageId: msgId, emoji });
}
});
}
// Keep toolbar overflow menus visible: flip below when top space is too small.
const updateToolbarOverflowDirection = (moreWrap) => {
if (!moreWrap) return;
const overflow = moreWrap.querySelector('.msg-toolbar-overflow, .thread-msg-overflow');
if (!overflow) return;
overflow.classList.remove('flip-below');
const container = moreWrap.closest('#messages, #thread-messages');
const containerRect = container
? container.getBoundingClientRect()
: { top: 0, bottom: window.innerHeight };
const moreRect = moreWrap.getBoundingClientRect();
const menuHeight = Math.max(overflow.scrollHeight, 40) + 8;
const spaceAbove = moreRect.top - containerRect.top;
const spaceBelow = containerRect.bottom - moreRect.bottom;
// Open downward when opening upward would clip in the current visible viewport.
if (spaceAbove < menuHeight && spaceBelow > spaceAbove) {
overflow.classList.add('flip-below');
}
};
const bindOverflowDirection = (container) => {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const moreWrap = e.target.closest('.msg-toolbar-more, .thread-msg-more');
if (!moreWrap) return;
updateToolbarOverflowDirection(moreWrap);
});
container.addEventListener('focusin', (e) => {
const moreWrap = e.target.closest('.msg-toolbar-more, .thread-msg-more');
if (!moreWrap) return;
updateToolbarOverflowDirection(moreWrap);
});
};
bindOverflowDirection(document.getElementById('messages'));
bindOverflowDirection(threadMessages);
// Reaction badge hover — show popout with user list
{
let _popoutTimer = null;
const msgs = document.getElementById('messages');
const threadMsgs = document.getElementById('thread-messages');
msgs.addEventListener('mouseover', (e) => {
const badge = e.target.closest('.reaction-badge');
if (!badge) return;
clearTimeout(_popoutTimer);
_popoutTimer = setTimeout(() => this._showReactionPopout(badge), 350);
});
if (threadMsgs) {
threadMsgs.addEventListener('mouseover', (e) => {
const badge = e.target.closest('.reaction-badge');
if (!badge) return;
clearTimeout(_popoutTimer);
_popoutTimer = setTimeout(() => this._showReactionPopout(badge), 350);
});
threadMsgs.addEventListener('mouseout', (e) => {
const badge = e.target.closest('.reaction-badge');
if (!badge && !e.target.closest('#reaction-popout')) {
clearTimeout(_popoutTimer);
setTimeout(() => {
if (!document.querySelector('#reaction-popout:hover')) this._hideReactionPopout();
}, 200);
}
});
}
msgs.addEventListener('mouseout', (e) => {
const badge = e.target.closest('.reaction-badge');
if (!badge && !e.target.closest('#reaction-popout')) {
clearTimeout(_popoutTimer);
setTimeout(() => {
if (!document.querySelector('#reaction-popout:hover')) this._hideReactionPopout();
}, 200);
}
});
document.addEventListener('mouseover', (e) => {
if (!e.target.closest('#reaction-popout') && !e.target.closest('.reaction-badge')) {
clearTimeout(_popoutTimer);
this._hideReactionPopout();
}
});
}
// ── Poll vote click (delegated from messages container) ──
document.getElementById('messages').addEventListener('click', (e) => {
const optBtn = e.target.closest('.poll-option');
@ -2307,6 +2682,7 @@ _setupUI() {
// Account deleted — clear local storage and redirect to login
localStorage.removeItem('haven_token');
localStorage.removeItem('haven_e2e_privkey');
localStorage.removeItem('haven_sync_key');
window.location.reload();
});
});
@ -2382,6 +2758,70 @@ _setupUI() {
});
}
// ── Server backup / restore (admin) ──────────────────
const startBackupDownload = (include) => {
const token = localStorage.getItem('haven_token');
if (!token) return this._showToast(t('toasts.not_logged_in') || 'Not logged in', 'error');
const url = `/api/admin/backup?include=${encodeURIComponent(include)}&token=${encodeURIComponent(token)}`;
const a = document.createElement('a');
a.href = url;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 1000);
this._showToast(t('toasts.backup_started') || 'Preparing backup…', 'info');
};
const getBackupIncludes = () => {
return Array.from(document.querySelectorAll('.backup-include:checked')).map(el => el.value);
};
document.getElementById('backup-download-btn')?.addEventListener('click', () => {
const includes = getBackupIncludes();
if (!includes.length) {
return this._showToast(t('toasts.backup_pick_one') || 'Pick at least one section to back up', 'error');
}
const heavy = includes.includes('messages') || includes.includes('files');
if (heavy && !confirm(t('confirm.backup_heavy') || 'This backup includes messages and/or uploaded files. It may take a while and produce a large download. Continue?')) return;
startBackupDownload(includes.join(','));
});
document.getElementById('backup-select-all-btn')?.addEventListener('click', () => {
document.querySelectorAll('.backup-include').forEach(el => { el.checked = true; });
});
document.getElementById('backup-select-none-btn')?.addEventListener('click', () => {
document.querySelectorAll('.backup-include').forEach(el => { el.checked = false; });
});
const restoreBtn = document.getElementById('backup-restore-btn');
if (restoreBtn) {
restoreBtn.addEventListener('click', async () => {
const fileInput = document.getElementById('backup-restore-file');
const file = fileInput?.files?.[0];
if (!file) return this._showToast(t('toasts.backup_no_file') || 'Choose a backup .zip file first', 'error');
if (!confirm(t('confirm.backup_restore') || 'Restore this backup? It will OVERWRITE the current server data and restart the server. This cannot be undone (except via the haven.db.pre-restore copy on the host machine).')) return;
const token = localStorage.getItem('haven_token');
if (!token) return this._showToast(t('toasts.not_logged_in') || 'Not logged in', 'error');
const fd = new FormData();
fd.append('backup', file);
restoreBtn.disabled = true;
const origText = restoreBtn.innerHTML;
restoreBtn.innerHTML = '⏳ Uploading…';
try {
const res = await fetch('/api/admin/restore', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: fd,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this._showToast(data.message || 'Restore staged. Server restarting…', 'success');
restoreBtn.innerHTML = '✓ Restarting…';
} catch (err) {
this._showToast((t('toasts.backup_restore_failed') || 'Restore failed: ') + err.message, 'error');
restoreBtn.disabled = false;
restoreBtn.innerHTML = origText;
}
});
}
// ── Whitelist controls (admin) ───────────────────────
// Whitelist toggle — saved via admin Save button
@ -2454,6 +2894,40 @@ _setupUI() {
});
},
// ═══════════════════════════════════════════════════════
// CHANNEL & MESSAGE LINKS — copy/share deep-links
// ═══════════════════════════════════════════════════════
/** Copy a Haven-style deep link to a channel (and optionally a message) to the clipboard. */
_copyChannelLink(code, messageId = null) {
if (!code) return;
const base = `${window.location.origin}/app.html?channel=${encodeURIComponent(code)}`;
const url = messageId ? `${base}&message=${encodeURIComponent(messageId)}` : base;
const onCopied = () => {
const key = messageId ? 'toasts.message_link_copied' : 'toasts.channel_link_copied';
const fallback = messageId ? 'Message link copied' : 'Channel link copied';
this._showToast(t(key) || fallback, 'success');
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onCopied).catch(() => this._copyTextFallback(url, onCopied));
} else {
this._copyTextFallback(url, onCopied);
}
},
_copyTextFallback(text, onCopied) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.focus(); ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onCopied?.();
} catch { /* could not copy */ }
},
// ═══════════════════════════════════════════════════════
// SERVER BAR — multi-server with live status
// ═══════════════════════════════════════════════════════
@ -2466,12 +2940,66 @@ _pushServerListToServer() {
}
},
/** Push all ServerManager entries to Desktop's global server history.
* This ensures servers discovered via encrypted sync propagate to
* OTHER Haven servers the next time the user switches. */
_pushServersToDesktopHistory() {
if (!window.havenDesktop?.addServerHistory || !window.havenDesktop?.getServerHistory) return;
window.havenDesktop.getServerHistory().then(history => {
const historyUrls = new Set((history || []).map(h => h.url));
for (const s of this.serverManager.getAll()) {
if (!historyUrls.has(s.url)) {
window.havenDesktop.addServerHistory(s.url, s.name).catch(() => {});
}
}
}).catch(() => {});
},
_setupServerBar() {
this.serverManager.startPolling(30000);
// Desktop: merge Electron's server history into the web ServerManager
// so the sidebar shows ALL known servers even on first login to this server
if (window.havenDesktop?.getServerHistory) {
window.havenDesktop.getServerHistory().then(history => {
const historyUrls = new Set((history || []).map(h => h.url));
const removed = this.serverManager._loadRemoved();
let added = false;
// Add Desktop servers to web ServerManager (skip removed ones)
for (const h of (history || [])) {
if (!h.url) continue;
let normalizedUrl;
try { normalizedUrl = new URL(h.url).origin; } catch { normalizedUrl = h.url; }
if (removed.has(h.url) || removed.has(normalizedUrl)) continue;
if (this.serverManager.add(h.name || h.url, h.url)) {
added = true;
}
}
// Add web ServerManager servers to Desktop history
if (window.havenDesktop.addServerHistory) {
for (const s of this.serverManager.getAll()) {
if (!historyUrls.has(s.url)) {
window.havenDesktop.addServerHistory(s.url, s.name).catch(() => {});
}
}
}
if (added) {
this._renderServerBar();
this._pushServerListToServer();
}
}).catch(() => {});
}
this._renderServerBar();
if (this._serverBarInterval) clearInterval(this._serverBarInterval);
this._serverBarInterval = setInterval(() => this._renderServerBar(), 30000);
// Re-render once the self-fingerprint resolves (hides "self" in sidebar)
this.serverManager.selfFingerprintReady?.then(() => this._renderServerBar());
// Desktop notification dots — listen for badge updates from main process
window.addEventListener('haven-server-badges', (e) => this._updateServerBadgeDots(e.detail));
window.havenDesktop?.getServerBadges?.().then(b => this._updateServerBadgeDots(b));
@ -2492,6 +3020,7 @@ _setupServerBar() {
document.getElementById('server-url-input').disabled = false;
document.getElementById('add-server-icon-input').value = '';
document.getElementById('save-server-btn').textContent = t('modals.add_server.add_btn');
this._populateKnownServersDatalist();
document.getElementById('add-server-name-input').focus();
});
@ -2528,6 +3057,47 @@ _setupServerBar() {
document.getElementById('add-server-btn').click();
});
// ── Sync Servers button ─────────────────────────────
document.getElementById('sync-servers-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('sync-servers-btn');
btn.classList.add('spinning');
try {
// 1. Pull from Desktop history (cross-server bridge)
if (window.havenDesktop?.getServerHistory) {
const history = await window.havenDesktop.getServerHistory();
const removed = this.serverManager._loadRemoved();
let added = false;
for (const h of (history || [])) {
if (!h.url) continue;
let normalizedUrl;
try { normalizedUrl = new URL(h.url).origin; } catch { normalizedUrl = h.url; }
if (removed.has(h.url) || removed.has(normalizedUrl)) continue;
if (this.serverManager.add(h.name || h.url, h.url)) added = true;
}
if (added) this._renderServerBar();
}
// 2. Pull from server-side encrypted backup
const syncKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (syncKey && this.serverManager && this.token) {
await this.serverManager.syncWithServer(this.token, syncKey);
}
// 3. Push merged list back to Desktop history + encrypted backup
this._pushServersToDesktopHistory();
this._pushServerListToServer();
// 4. Health-check all servers
await this.serverManager.checkAll();
this._renderServerBar();
this._showToast('Server list synced', 'success');
} catch {
this._showToast('Sync failed', 'error');
} finally {
btn.classList.remove('spinning');
}
});
// ── Channel Code Settings Modal ─────────────────────
document.getElementById('channel-code-settings-btn')?.addEventListener('click', () => {
if (!this.currentChannel || (!this.user.isAdmin && !this._hasPerm('create_channel'))) return;
@ -2594,6 +3164,52 @@ _toggleCodeRotationFields() {
if (label) label.textContent = type === 'time' ? t('modals.code_settings.interval_label') : t('modals.code_settings.rotate_after_joins');
},
/** Populate the datalist in the Add Server modal with known servers from
* the web ServerManager and (if in Desktop) the Electron server history. */
async _populateKnownServersDatalist() {
const datalist = document.getElementById('known-servers-datalist');
if (!datalist) return;
datalist.innerHTML = '';
// Collect from web ServerManager
const known = new Map(); // url → name
for (const s of this.serverManager.getAll()) {
known.set(s.url, s.name || s.url);
}
// Collect from Desktop server history (if running in Electron)
if (window.havenDesktop?.getServerHistory) {
try {
const history = await window.havenDesktop.getServerHistory();
for (const h of (history || [])) {
if (h.url && !known.has(h.url)) known.set(h.url, h.name || h.url);
}
} catch { /* not available */ }
}
// Build datalist options
for (const [url, name] of known) {
const opt = document.createElement('option');
opt.value = url;
opt.label = name !== url ? name : '';
datalist.appendChild(opt);
}
// When the user picks a server from the list, auto-fill the name field
const urlInput = document.getElementById('server-url-input');
const nameInput = document.getElementById('add-server-name-input');
const onChange = () => {
const match = known.get(urlInput.value);
if (match && !nameInput.value) {
nameInput.value = match;
}
};
// Remove previous listener to avoid stacking
urlInput.removeEventListener('change', urlInput._knownServerHandler);
urlInput._knownServerHandler = onChange;
urlInput.addEventListener('change', onChange);
},
_addServer() {
const name = document.getElementById('add-server-name-input').value.trim();
const url = document.getElementById('server-url-input').value.trim();
@ -2619,6 +3235,12 @@ _addServer() {
this._renderServerBar();
this._showToast(t('toasts.server_added', { name }), 'success');
this._pushServerListToServer();
// Also add to Desktop server history so it persists across all servers
if (window.havenDesktop?.addServerHistory) {
const cleanUrl = url.replace(/\/+$/, '');
const finalUrl = /^https?:\/\//.test(cleanUrl) ? cleanUrl : 'https://' + cleanUrl;
window.havenDesktop.addServerHistory(finalUrl, name).catch(() => {});
}
// Auto-pull icon after health check completes
if (autoPull) {
const cleanUrl = url.replace(/\/+$/, '');
@ -2660,13 +3282,22 @@ _openManageServersModal() {
_renderManageServersList() {
const container = document.getElementById('manage-servers-list');
const servers = this.serverManager.getAll();
const currentOrigin = window.location.origin;
const selfFp = this.serverManager.selfFingerprint;
const servers = this.serverManager.getAll().filter(s => {
if (selfFp && s.status.fingerprint === selfFp) return false;
try { return new URL(s.url).origin !== currentOrigin; } catch { return true; }
});
container.innerHTML = '';
if (servers.length === 0) return; // CSS :empty handles empty state
let dragSrcRow = null;
servers.forEach(s => {
const row = document.createElement('div');
row.className = 'manage-server-row';
row.draggable = true;
row.dataset.url = s.url;
const online = s.status.online;
const statusClass = online === true ? 'online' : online === false ? 'offline' : 'unknown';
@ -2678,6 +3309,7 @@ _renderManageServersList() {
: initial;
row.innerHTML = `
<div class="manage-server-drag-handle" title="Drag to reorder"></div>
<div class="manage-server-icon">${iconContent}</div>
<div class="manage-server-info">
<div class="manage-server-name">${this._escapeHtml(s.name)}</div>
@ -2691,6 +3323,48 @@ _renderManageServersList() {
</div>
`;
// ── Drag-and-drop handlers ──
row.addEventListener('dragstart', (e) => {
dragSrcRow = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.url);
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
container.querySelectorAll('.manage-server-row').forEach(r => r.classList.remove('drag-over-above', 'drag-over-below'));
dragSrcRow = null;
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragSrcRow === row) return;
const rect = row.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
row.classList.toggle('drag-over-above', e.clientY < mid);
row.classList.toggle('drag-over-below', e.clientY >= mid);
});
row.addEventListener('dragleave', () => {
row.classList.remove('drag-over-above', 'drag-over-below');
});
row.addEventListener('drop', (e) => {
e.preventDefault();
row.classList.remove('drag-over-above', 'drag-over-below');
if (!dragSrcRow || dragSrcRow === row) return;
const rect = row.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
container.insertBefore(dragSrcRow, row);
} else {
container.insertBefore(dragSrcRow, row.nextSibling);
}
// Persist the new order
const orderedUrls = [...container.querySelectorAll('.manage-server-row')].map(r => r.dataset.url);
this.serverManager.reorder(orderedUrls);
this._renderServerBar();
this._pushServerListToServer();
});
row.querySelector('.manage-server-visit').addEventListener('click', () => {
if (window.havenDesktop?.switchServer) {
window.havenDesktop.switchServer(s.url);
@ -2736,9 +3410,26 @@ _updateServerBadgeDots(badges) {
});
},
// Append a stable cache-buster query param to icon URLs. This forces the
// browser to bypass any pre-CORS cached response for the same image (which
// causes "No Access-Control-Allow-Origin" errors when a non-crossorigin
// load was cached without the proper Vary: Origin header). See #5240.
_withCacheBust(url) {
if (!url || typeof url !== 'string') return url;
if (url.startsWith('data:') || url.startsWith('blob:')) return url;
// 'cors2' marks the post-Vary/CORP header fix; bump if the cache invariant changes again.
const tag = 'cors2';
return url + (url.includes('?') ? '&' : '?') + '_cb=' + tag;
},
_renderServerBar() {
const list = document.getElementById('server-list');
const servers = this.serverManager.getAll();
const currentOrigin = window.location.origin;
const selfFp = this.serverManager.selfFingerprint;
const servers = this.serverManager.getAll().filter(s => {
if (selfFp && s.status.fingerprint === selfFp) return false;
try { return new URL(s.url).origin !== currentOrigin; } catch { return true; }
});
list.innerHTML = servers.map(s => {
const initial = s.name.charAt(0).toUpperCase();
@ -2747,8 +3438,12 @@ _renderServerBar() {
const statusText = online === true ? '● ' + t('servers.online') : online === false ? '○ ' + t('servers.offline') : '◌ ' + t('servers.checking');
// Use custom icon, auto-pulled icon from health check, or letter initial
const iconUrl = s.icon || (s.status.icon || null);
const iconContent = iconUrl
? `<img src="${this._escapeHtml(iconUrl)}" class="server-icon-img"${s.iconData ? ` data-fallback-src="${this._escapeHtml(s.iconData)}"` : ''} alt=""><span class="server-icon-text" style="display:none">${this._escapeHtml(initial)}</span>`
// Append a stable cache-buster so browsers don't reuse a bad pre-CORS
// cached response (which causes "No Access-Control-Allow-Origin" errors
// on icons that were loaded once without the crossorigin attribute). See #5240.
const bustedIcon = iconUrl ? this._withCacheBust(iconUrl) : null;
const iconContent = bustedIcon
? `<img src="${this._escapeHtml(bustedIcon)}" class="server-icon-img" crossorigin="anonymous"${s.iconData ? ` data-fallback-src="${this._escapeHtml(s.iconData)}"` : ''} alt=""><span class="server-icon-text" style="display:none">${this._escapeHtml(initial)}</span>`
: (s.iconData
? `<img src="${this._escapeHtml(s.iconData)}" class="server-icon-img" alt=""><span class="server-icon-text" style="display:none">${this._escapeHtml(initial)}</span>`
: `<span class="server-icon-text">${this._escapeHtml(initial)}</span>`);
@ -3101,8 +3796,9 @@ _renderMobileServerList() {
const online = s.status.online;
const dotClass = online === true ? 'online' : online === false ? 'offline' : 'unknown';
const iconUrl = s.icon || (s.status.icon || null);
const iconHtml = iconUrl
? `<img src="${this._escapeHtml(iconUrl)}" class="msrv-icon" alt="">`
const bustedIcon = iconUrl ? this._withCacheBust(iconUrl) : null;
const iconHtml = bustedIcon
? `<img src="${this._escapeHtml(bustedIcon)}" class="msrv-icon" alt="" crossorigin="anonymous">`
+ `<span class="msrv-initial" style="display:none">${initial}</span>`
: `<span class="msrv-initial">${initial}</span>`;
return `<a class="mobile-server-item" href="${this._escapeHtml(s.url)}" target="_blank" rel="noopener">
@ -3126,7 +3822,12 @@ _renderMobileServerList() {
_renderMobileSidebarServers() {
const scroll = document.getElementById('mobile-servers-scroll');
if (!scroll || !this.serverManager) return;
const servers = this.serverManager.getAll();
const currentOrigin = window.location.origin;
const selfFp = this.serverManager.selfFingerprint;
const servers = this.serverManager.getAll().filter(s => {
if (selfFp && s.status.fingerprint === selfFp) return false;
try { return new URL(s.url).origin !== currentOrigin; } catch { return true; }
});
if (servers.length === 0) {
scroll.innerHTML = `<span class="mobile-servers-empty">${t('servers.no_servers')}</span>`;
return;
@ -3136,8 +3837,9 @@ _renderMobileSidebarServers() {
const online = s.status.online;
const dotClass = online === true ? 'online' : online === false ? 'offline' : 'unknown';
const iconUrl = s.icon || (s.status.icon || null);
const iconHtml = iconUrl
? `<img src="${this._escapeHtml(iconUrl)}" alt="${this._escapeHtml(initial)}" class="mobile-srv-icon-img">`
const bustedIcon = iconUrl ? this._withCacheBust(iconUrl) : null;
const iconHtml = bustedIcon
? `<img src="${this._escapeHtml(bustedIcon)}" alt="${this._escapeHtml(initial)}" class="mobile-srv-icon-img" crossorigin="anonymous">`
: `<span>${this._escapeHtml(initial)}</span>`;
return `<a class="mobile-srv-bubble" href="${this._escapeHtml(s.url)}" target="_blank" rel="noopener" title="${this._escapeHtml(s.name)}">
${iconHtml}
@ -3294,12 +3996,60 @@ _submitPoll() {
// the message input stays visible above the keyboard.
_setupIOSKeyboard() {
if (!window.visualViewport) return;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isIOS) return;
// ── Safe-area probing (all mobile, but especially iOS) ──
// env(safe-area-inset-*) sometimes returns 0 even on notched devices
// (e.g. certain iOS versions in browser vs PWA mode, or when CSS env()
// isn't evaluated). We probe the actual value via a hidden element and
// set CSS custom properties with a minimum floor as fallback.
if (isIOS || /Android/.test(navigator.userAgent)) {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
document.body.classList.add(isIOS ? 'is-ios' : 'is-android');
// Detect standalone PWA mode
if (isIOS && (window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches)) {
document.body.classList.add('is-ios-pwa');
}
// Probe env(safe-area-inset-top) by measuring a hidden div
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;top:0;left:0;width:1px;pointer-events:none;visibility:hidden;'
+ 'height:env(safe-area-inset-top,0px);height:constant(safe-area-inset-top)';
document.body.appendChild(probe);
requestAnimationFrame(() => {
const measuredTop = probe.offsetHeight;
probe.style.cssText = 'position:fixed;bottom:0;left:0;width:1px;pointer-events:none;visibility:hidden;'
+ 'height:env(safe-area-inset-bottom,0px);height:constant(safe-area-inset-bottom)';
requestAnimationFrame(() => {
const measuredBottom = probe.offsetHeight;
document.body.removeChild(probe);
// Determine minimum safe-area for this device
let minTop = 0, minBottom = 0;
if (isIOS) {
const h = window.screen.height;
// iPhone X+ / Dynamic Island (screen height >= 812pt)
if (h >= 812) { minTop = 47; minBottom = 34; }
// Older iPhones
else { minTop = 20; minBottom = 0; }
}
const safeTop = Math.max(measuredTop, minTop);
const safeBottom = Math.max(measuredBottom, minBottom);
const root = document.documentElement;
root.style.setProperty('--safe-top', safeTop + 'px');
root.style.setProperty('--safe-bottom', safeBottom + 'px');
});
});
}
}
if (!window.visualViewport || !isIOS) return;
const app = document.getElementById('app');
const messages = document.getElementById('messages');

View file

@ -209,9 +209,18 @@ _formatContent(str) {
// Render `inline code`
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Render > blockquotes (lines starting with >)
html = html.replace(/(?:^|\n)&gt;\s?(.+)/g, (_, text) => {
return `\n<blockquote class="chat-blockquote">${text}</blockquote>`;
// Render grouped > blockquotes and preserve attribution lines inside the quote.
const blockquotes = [];
html = html.replace(/(^|\n)((?:&gt;[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
const lines = block.trim().split('\n').map(line => line.replace(/^&gt;\s?/, ''));
let authorHtml = '';
if (lines[0] && /^@[^\s].+ wrote:$/.test(lines[0])) {
authorHtml = `<div class="chat-blockquote-author">${lines.shift()}</div>`;
}
const textHtml = lines.join('<br>');
const idx = blockquotes.length;
blockquotes.push(`${pre}<blockquote class="chat-blockquote">${authorHtml}<div class="chat-blockquote-body">${textHtml}</div></blockquote>`);
return `\x00BLOCKQUOTE_${idx}\x00`;
});
// ── Headings: # H1, ## H2, ### H3 at start of line ──
@ -243,6 +252,10 @@ _formatContent(str) {
html = html.replace(/\n/g, '<br>');
blockquotes.forEach((block, idx) => {
html = html.replace(`\x00BLOCKQUOTE_${idx}\x00`, block);
});
// ── Restore fenced code blocks ──
codeBlocks.forEach((block, idx) => {
const escaped = this._escapeHtml(block.code).replace(/\n$/, '');
@ -919,6 +932,7 @@ _renderReactions(msgId, reactions) {
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(', ');
const usersJson = this._escapeHtml(JSON.stringify(g.users.map(u => u.username)));
// Check if it's a custom emoji
const customMatch = g.emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = g.emoji;
@ -926,7 +940,7 @@ _renderReactions(msgId, reactions) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(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>`;
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" data-users="${usersJson}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
}).join('');
return `<div class="reactions-row">${badges}</div>`;
@ -946,8 +960,8 @@ _updateMessageReactions(messageId, reactions) {
const html = this._renderReactions(messageId, reactions);
if (!html) { if (wasAtBottom) this._scrollToBottom(true); return; }
// Find where to insert — after .message-content
const content = msgEl.querySelector('.message-content');
// Find where to insert — after main or thread message content
const content = msgEl.querySelector('.message-content, .thread-msg-content');
if (content) {
content.insertAdjacentHTML('afterend', html);
}
@ -955,6 +969,49 @@ _updateMessageReactions(messageId, reactions) {
if (wasAtBottom) this._scrollToBottom(true);
},
// ── Reaction popout (who reacted) ─────────────────────
_showReactionPopout(badge) {
this._hideReactionPopout();
let users;
try { users = JSON.parse(badge.dataset.users || '[]'); } catch { return; }
if (!users.length) return;
const emoji = badge.dataset.emoji;
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = emoji;
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
}
const popout = document.createElement('div');
popout.id = 'reaction-popout';
popout.className = 'reaction-popout';
popout.innerHTML = `
<div class="reaction-popout-header">${emojiDisplay} <span class="reaction-popout-count">${users.length}</span></div>
<div class="reaction-popout-list">
${users.map(u => `<div class="reaction-popout-user">${this._escapeHtml(u)}</div>`).join('')}
</div>
`;
document.body.appendChild(popout);
// Position above the badge
const rect = badge.getBoundingClientRect();
popout.style.left = rect.left + 'px';
popout.style.top = (rect.top - popout.offsetHeight - 6) + 'px';
// Clamp to viewport
const pr = popout.getBoundingClientRect();
if (pr.right > window.innerWidth) popout.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.left < 0) popout.style.left = '8px';
if (pr.top < 0) popout.style.top = (rect.bottom + 6) + 'px';
},
_hideReactionPopout() {
const existing = document.getElementById('reaction-popout');
if (existing) existing.remove();
},
_getQuickEmojis() {
const saved = localStorage.getItem('haven_quick_emojis');
if (saved) {
@ -1179,17 +1236,10 @@ _showReactionPicker(msgEl, msgId) {
// Flip picker below the message if it would be clipped above
requestAnimationFrame(() => {
const pickerRect = picker.getBoundingClientRect();
if (pickerRect.top < 0) {
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (pickerRect.top < containerTop + 4) {
picker.classList.add('flip-below');
} else {
// Also check against the messages container top (channel header/topic)
const container = document.getElementById('messages');
if (container) {
const containerRect = container.getBoundingClientRect();
if (pickerRect.top < containerRect.top) {
picker.classList.add('flip-below');
}
}
}
});
@ -1311,11 +1361,308 @@ _showFullReactionPicker(msgEl, msgId, quickPicker) {
searchTimer = setTimeout(() => renderAll(searchInput.value.trim()), 150);
});
// Position the panel near the quick picker
msgEl.appendChild(panel);
// Position the panel relative to quick picker so it never overlaps it
quickPicker.appendChild(panel);
if (quickPicker.classList.contains('flip-below')) {
panel.classList.add('flip-below');
}
requestAnimationFrame(() => {
const panelRect = panel.getBoundingClientRect();
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (panelRect.top < containerTop + 4) {
panel.classList.add('flip-below');
}
});
searchInput.focus();
},
// ═══════════════════════════════════════════════════════
// THREADS
// ═══════════════════════════════════════════════════════
_renderThreadPreview(parentId, thread) {
if (!thread || !thread.count) return '';
const participantAvatars = (thread.participants || []).map(p => {
if (p.avatar) {
return `<img class="thread-participant-avatar" src="${this._escapeHtml(p.avatar)}" alt="${this._escapeHtml(p.username)}" title="${this._escapeHtml(p.username)}">`;
}
const color = this._getUserColor(p.username);
const initial = p.username.charAt(0).toUpperCase();
return `<div class="thread-participant-avatar thread-participant-initial" style="background:${color}" title="${this._escapeHtml(p.username)}">${initial}</div>`;
}).join('');
const timeAgo = this._relativeTime(thread.lastReplyAt);
return `
<button class="thread-preview" data-thread-parent="${parentId}">
${participantAvatars}
<span class="thread-preview-count">${thread.count} ${thread.count === 1 ? 'Reply' : 'Replies'}</span>
<span class="thread-preview-time">${timeAgo}</span>
<span class="thread-preview-arrow"></span>
</button>
`;
},
_relativeTime(isoStr) {
if (!isoStr) return '';
const diff = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
},
_setThreadParentHeader(meta = {}) {
const wrap = document.getElementById('thread-parent-avatar-wrap');
const nameEl = document.getElementById('thread-parent-name');
if (!wrap || !nameEl) return;
const username = (meta.username || '').trim() || 'Thread starter';
const shape = (meta.avatarShape || 'circle') === 'square' ? 'square' : 'circle';
const shapeClass = shape === 'square' ? ' thread-parent-avatar-square' : '';
if (meta.avatar) {
wrap.innerHTML = `<img class="thread-parent-avatar${shapeClass}" src="${this._escapeHtml(meta.avatar)}" alt="${this._escapeHtml(username)}">`;
} else {
const initial = username.charAt(0).toUpperCase() || '?';
const color = this._getUserColor(username);
wrap.innerHTML = `<div class="thread-parent-avatar-initial${shapeClass}" style="background:${color}">${this._escapeHtml(initial)}</div>`;
}
nameEl.textContent = username;
nameEl.title = username;
},
_setThreadReply(msgEl, msgId) {
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const preview = rawContent.length > 70 ? rawContent.substring(0, 70) + '…' : rawContent;
this._threadReplyingTo = { id: msgId, username: author, content: rawContent };
const bar = document.getElementById('thread-reply-bar');
const text = document.getElementById('thread-reply-preview-text');
if (!bar || !text) return;
bar.style.display = 'flex';
text.innerHTML = `Replying to <strong>${this._escapeHtml(author)}</strong>: ${this._escapeHtml(preview)}`;
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_clearThreadReply() {
this._threadReplyingTo = null;
const bar = document.getElementById('thread-reply-bar');
if (bar) bar.style.display = 'none';
},
_quoteThreadMessage(msgEl) {
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('thread-input');
if (!input) return;
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
input.dispatchEvent(new Event('input'));
},
_openThread(parentId) {
this._activeThreadParent = parentId;
const panel = document.getElementById('thread-panel');
if (!panel) return;
panel.style.display = 'flex';
panel.dataset.parentId = parentId;
this._setThreadPiPEnabled(localStorage.getItem('haven_thread_panel_pip') === '1');
// Request thread messages from server
this.socket.emit('get-thread-messages', { parentId });
// Update header
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
const author = msgEl?.querySelector('.message-author')?.textContent || 'Thread starter';
document.getElementById('thread-panel-title').textContent = 'Thread';
const parentPreview = msgEl?.querySelector('.message-content')?.textContent || '';
document.getElementById('thread-parent-preview').textContent = parentPreview.length > 120 ? parentPreview.substring(0, 120) + '…' : parentPreview;
const avatarImg = msgEl?.querySelector('.message-avatar-img');
let avatar = null;
if (avatarImg && avatarImg.getAttribute('src')) avatar = avatarImg.getAttribute('src');
const avatarShape = (avatarImg && avatarImg.classList.contains('avatar-square')) ? 'square' : 'circle';
this._setThreadParentHeader({ username: author, avatar, avatarShape });
// Focus input
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_setThreadPiPEnabled(enabled) {
const panel = document.getElementById('thread-panel');
const pipBtn = document.getElementById('thread-panel-pip');
if (!panel || !pipBtn) return;
const isOn = !!enabled;
panel.classList.toggle('pip', isOn);
pipBtn.textContent = isOn ? '▣' : '⧉';
pipBtn.title = isOn ? 'Dock thread panel' : 'Pop out thread (PiP)';
pipBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
localStorage.setItem('haven_thread_panel_pip', isOn ? '1' : '0');
if (isOn) {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('haven_thread_panel_pip_rect') || 'null'); } catch {}
const minW = 320;
const maxW = Math.min(760, window.innerWidth - 28);
const minH = 240;
const footerOffset = (() => {
const raw = getComputedStyle(document.body).getPropertyValue('--thread-footer-offset');
const v = parseInt(raw, 10);
return Number.isFinite(v) ? v : 0;
})();
const maxH = Math.max(minH, window.innerHeight - footerOffset - 28);
const width = Math.max(minW, Math.min(maxW, (saved && saved.width) || panel.offsetWidth || 420));
const height = Math.max(minH, Math.min(maxH, (saved && saved.height) || panel.offsetHeight || 460));
const defaultLeft = Math.max(0, window.innerWidth - width - 14);
const defaultTop = Math.max(0, window.innerHeight - footerOffset - height - 14);
const left = Math.max(0, Math.min(window.innerWidth - width, (saved && Number.isFinite(saved.left)) ? saved.left : defaultLeft));
const top = Math.max(0, Math.min(window.innerHeight - footerOffset - height, (saved && Number.isFinite(saved.top)) ? saved.top : defaultTop));
panel.style.width = `${Math.round(width)}px`;
panel.style.height = `${Math.round(height)}px`;
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
} else {
panel.style.height = '';
panel.style.left = '';
panel.style.top = '';
panel.style.right = '';
panel.style.bottom = '';
}
},
_toggleThreadPiP() {
const panel = document.getElementById('thread-panel');
if (!panel) return;
this._setThreadPiPEnabled(!panel.classList.contains('pip'));
},
_closeThread() {
this._activeThreadParent = null;
this._clearThreadReply();
const panel = document.getElementById('thread-panel');
if (panel) {
panel.style.display = 'none';
panel.dataset.parentId = '';
}
},
_sendThreadMessage() {
const input = document.getElementById('thread-input');
if (!input) return;
const content = input.value.trim();
if (!content) return;
const parentId = this._activeThreadParent;
if (!parentId) return;
const replyTo = this._threadReplyingTo ? this._threadReplyingTo.id : null;
this.socket.emit('send-thread-message', { parentId, content, replyTo }, (resp) => {
if (resp && resp.error) {
this._showToast(resp.error, 'error');
return;
}
this._clearThreadReply();
});
input.value = '';
},
_appendThreadMessage(msg) {
const container = document.getElementById('thread-messages');
if (!container) return;
const color = this._getUserColor(msg.username);
const initial = msg.username.charAt(0).toUpperCase();
let avatarHtml;
if (msg.avatar) {
avatarHtml = `<img class="thread-msg-avatar" src="${this._escapeHtml(msg.avatar)}" alt="${initial}">`;
} else {
avatarHtml = `<div class="thread-msg-avatar thread-msg-avatar-initial" style="background:${color}">${initial}</div>`;
}
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
const canEdit = msg.user_id === this.user.id;
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
const iReact = iconPair('😀', '<svg class="thread-action-react-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
const threadCoreToolbarBtns = `<button data-thread-action="react" title="React" aria-label="React">${iReact}</button><button data-thread-action="reply" title="Reply">${iReply}</button><button data-thread-action="quote" title="Quote">${iQuote}</button>`;
let threadOverflowToolbarBtns = '';
if (canEdit) threadOverflowToolbarBtns += `<button data-thread-action="edit" title="Edit">${iEdit}</button>`;
if (canDelete) threadOverflowToolbarBtns += `<button data-thread-action="delete" title="Delete">${iDelete}</button>`;
const threadOverflowHtml = threadOverflowToolbarBtns
? `<div class="thread-msg-more"><button class="thread-msg-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="thread-msg-overflow">${threadOverflowToolbarBtns}</div></div>`
: '';
const el = document.createElement('div');
el.className = 'thread-message';
el.dataset.msgId = msg.id;
el.dataset.rawContent = msg.content;
el.innerHTML = `
<div class="thread-msg-row">
${avatarHtml}
<div class="thread-msg-body">
<div class="thread-msg-header">
<span class="thread-msg-author" style="color:${color}">${this._escapeHtml(msg.username)}</span>
<span class="thread-msg-time">${this._formatTime(msg.created_at)}</span>
<span class="thread-msg-header-spacer"></span>
<div class="thread-msg-toolbar">
<div class="msg-toolbar-group">${threadCoreToolbarBtns}</div>
${threadOverflowHtml}
</div>
</div>
${replyHtml}
<div class="thread-msg-content">${this._formatContent(msg.content)}</div>
${reactionsHtml}
</div>
</div>
`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
},
_updateThreadPreview(parentId, thread) {
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
if (!msgEl) return;
const oldPreview = msgEl.querySelector('.thread-preview');
const newHtml = this._renderThreadPreview(parentId, thread);
if (oldPreview) {
oldPreview.outerHTML = newHtml;
} else if (newHtml) {
// Insert after reactions row, or after message-content
const reactions = msgEl.querySelector('.reactions-row');
const content = msgEl.querySelector('.message-content');
const insertAfter = reactions || content;
if (insertAfter) insertAfter.insertAdjacentHTML('afterend', newHtml);
}
},
// ═══════════════════════════════════════════════════════
// REPLY
// ═══════════════════════════════════════════════════════
@ -1365,6 +1712,38 @@ _clearReply() {
if (bar) bar.style.display = 'none';
},
_quoteMessage(msgEl) {
// Get the raw text content of the message
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.textContent || '';
// Get the author name
let author = msgEl.querySelector('.message-author')?.textContent;
if (!author) {
let prev = msgEl.previousElementSibling;
while (prev) {
const authorEl = prev.querySelector('.message-author');
if (authorEl) { author = authorEl.textContent; break; }
prev = prev.previousElementSibling;
}
}
author = author || 'someone';
// Build the blockquote text — each line prefixed with >
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('message-input');
// If there's already text, add a newline before the quote
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
// Trigger input event so textarea auto-resizes
input.dispatchEvent(new Event('input'));
},
// ═══════════════════════════════════════════════════════
// EDIT MESSAGE
// ═══════════════════════════════════════════════════════
@ -1373,7 +1752,7 @@ _startEditMessage(msgEl, msgId) {
// Guard against re-entering edit mode
if (msgEl.classList.contains('editing')) return;
const contentEl = msgEl.querySelector('.message-content');
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
if (!contentEl) return;
// Use the stored raw markdown content (set on render and kept in sync on

View file

@ -14,6 +14,8 @@ class NotificationManager {
this.volume = this._loadPref('haven_notif_volume', 0.5);
this.mentionVolume = this._loadPref('haven_notif_mention_volume', 0.8);
this.replyVolume = this._loadPref('haven_notif_reply_volume', 0.8);
this.joinVolume = this._loadPref('haven_notif_join_volume', 0.8);
this.leaveVolume = this._loadPref('haven_notif_leave_volume', 0.8);
this.sounds = {
message: this._loadPref('haven_notif_msg_sound', 'ping'),
sent: this._loadPref('haven_notif_sent_sound', 'swoosh'),
@ -50,7 +52,7 @@ class NotificationManager {
// ── Synth Tone Engine ───────────────────────────────────
_playTone(frequencies, durations, type = 'sine') {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
try {
const ctx = this._getCtx();
const masterGain = ctx.createGain();
@ -100,7 +102,7 @@ class NotificationManager {
// ── Custom Sound File Playback ──────────────────────────
_playFile(url) {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
try {
let audio = this._audioCache[url];
if (!audio) {
@ -139,21 +141,27 @@ class NotificationManager {
// ── Public API ──────────────────────────────────────────
play(event, opts) {
// Per-type opt-in check: mentions, replies, DMs always play if their toggle is on
// Per-type opt-in: mentions, replies, DMs bypass master toggle if their own toggle is on
if (opts && opts.isMention && this.mentionsEnabled) { /* allowed */ }
else if (opts && opts.isReply && this.repliesEnabled) { /* allowed */ }
else if (opts && opts.isDm && this.dmEnabled) { /* allowed */ }
else if (!this.enabled) return;
// Regular message & announcement sounds are gated by the master toggle;
// everything else (sent, join, leave) always plays
else if ((event === 'message' || event === 'announcement') && !this.enabled) return;
const sound = this.sounds[event];
if (!sound || sound === 'none') return;
// Use mention volume if this is a mention event
// Use per-event volume if set
const origVol = this.volume;
if (event === 'mention') {
this.volume = this.mentionVolume;
} else if (event === 'reply') {
this.volume = this.replyVolume;
} else if (event === 'join') {
this.volume = this.joinVolume;
} else if (event === 'leave') {
this.volume = this.leaveVolume;
}
// Custom uploaded sound (format: "custom:soundname")
@ -186,7 +194,7 @@ class NotificationManager {
/** Play a named tone directly (bypasses event→sound mapping). Used for UI cues. */
playDirect(toneName) {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
if (typeof this[toneName] === 'function') this[toneName]();
}
@ -210,6 +218,16 @@ class NotificationManager {
this._savePref('haven_notif_reply_volume', this.replyVolume);
}
setJoinVolume(val) {
this.joinVolume = Math.max(0, Math.min(1, val));
this._savePref('haven_notif_join_volume', this.joinVolume);
}
setLeaveVolume(val) {
this.leaveVolume = Math.max(0, Math.min(1, val));
this._savePref('haven_notif_leave_volume', this.leaveVolume);
}
setSound(event, sound) {
this.sounds[event] = sound;
this._savePref(`haven_notif_${event}_sound`, sound);

View file

@ -8,11 +8,39 @@ class ServerManager {
this.servers = this._load();
this.statusCache = new Map();
this.checkInterval = null;
this.selfFingerprint = null;
this.selfFingerprintReady = this._fetchSelfFingerprint();
}
/** Fetch the current server's fingerprint so we can hide "self" from the sidebar. */
async _fetchSelfFingerprint() {
try {
const res = await fetch('/api/health');
if (res.ok) {
const data = await res.json();
if (data.fingerprint) this.selfFingerprint = data.fingerprint;
}
} catch {}
}
_load() {
try {
return JSON.parse(localStorage.getItem('haven_servers') || '[]');
const raw = JSON.parse(localStorage.getItem('haven_servers') || '[]');
// Normalize URLs on load to dedup legacy entries while preserving
// subpath-hosted servers like https://host/community.
const seen = new Set();
const deduped = [];
for (const s of raw) {
const normalizedUrl = this._normalizeUrl(s?.url || '');
if (!normalizedUrl || seen.has(normalizedUrl)) continue;
s.url = normalizedUrl;
seen.add(normalizedUrl);
deduped.push(s);
}
if (deduped.length !== raw.length) {
localStorage.setItem('haven_servers', JSON.stringify(deduped));
}
return deduped;
} catch { return []; }
}
@ -21,9 +49,15 @@ class ServerManager {
}
add(name, url, icon = null) {
url = url.replace(/\/+$/, '');
if (!/^https?:\/\//.test(url)) url = 'https://' + url;
if (this.servers.find(s => s.url === url)) return false;
url = this._normalizeUrl(url);
if (this.servers.find(s => this._normalizeUrl(s.url) === url)) return false;
// User explicitly adding — clear from removed set so sync won't fight it
const removed = this._loadRemoved();
if (removed.has(url)) {
removed.delete(url);
this._saveRemoved(removed);
}
this.servers.push({ name, url, icon, addedAt: Date.now() });
this._save();
@ -32,7 +66,8 @@ class ServerManager {
}
update(url, updates) {
const server = this.servers.find(s => s.url === url);
const normalizedUrl = this._normalizeUrl(url);
const server = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (!server) return false;
if (updates.name !== undefined) server.name = updates.name;
if (updates.icon !== undefined) server.icon = updates.icon;
@ -41,8 +76,24 @@ class ServerManager {
}
remove(url) {
this.servers = this.servers.filter(s => s.url !== url);
this.statusCache.delete(url);
const normalizedUrl = this._normalizeUrl(url);
this.servers = this.servers.filter(s => this._normalizeUrl(s.url) !== normalizedUrl);
this.statusCache.delete(normalizedUrl);
this._save();
this.markRemoved(normalizedUrl);
}
/** Reorder servers by an array of URLs in the desired order. */
reorder(orderedUrls) {
const map = new Map(this.servers.map(s => [s.url, s]));
const reordered = [];
for (const url of orderedUrls) {
const s = map.get(url);
if (s) { reordered.push(s); map.delete(url); }
}
// Append any servers not in the ordered list (shouldn't happen, but safe)
for (const s of map.values()) reordered.push(s);
this.servers = reordered;
this._save();
}
@ -54,14 +105,12 @@ class ServerManager {
}
async checkServer(url) {
const normalizedUrl = this._normalizeUrl(url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
// Use only the origin for health checks — if someone stored a URL
// like https://example.com/app, we don't want /app/api/health (404).
let healthBase;
try { healthBase = new URL(url).origin; } catch { healthBase = url; }
const healthBase = normalizedUrl;
const res = await fetch(`${healthBase}/api/health`, {
signal: controller.signal,
@ -71,35 +120,40 @@ class ServerManager {
if (res.ok) {
const data = await res.json();
const discoveredIcon = data.icon ? `${url}${data.icon}` : null;
this.statusCache.set(url, {
const discoveredIcon = data.icon ? new URL(data.icon, `${healthBase}/`).toString() : null;
this.statusCache.set(normalizedUrl, {
online: true,
name: data.name || url,
name: data.name || normalizedUrl,
icon: discoveredIcon,
version: data.version,
fingerprint: data.fingerprint || null,
checkedAt: Date.now()
});
// Persist discovered icon to the server entry so it survives
// across page reloads and offline periods
if (discoveredIcon) {
const entry = this.servers.find(s => s.url === url);
if (entry && !entry.icon) {
entry.icon = discoveredIcon;
this._save();
}
// Generate a small base64 thumbnail so the icon travels
// with the encrypted sync bundle across servers
if (entry && !entry.iconData) {
this._fetchIconThumbnail(discoveredIcon).then(dataUrl => {
if (dataUrl) { entry.iconData = dataUrl; this._save(); }
});
const entry = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (entry) {
// Always update the icon URL (server may have changed its icon)
if (entry.icon !== discoveredIcon) {
entry.icon = discoveredIcon;
entry.iconData = null; // clear stale thumbnail
this._save();
}
// Generate a small base64 thumbnail so the icon travels
// with the encrypted sync bundle across servers
if (!entry.iconData) {
this._fetchIconThumbnail(discoveredIcon).then(dataUrl => {
if (dataUrl) { entry.iconData = dataUrl; this._save(); }
});
}
}
}
} else {
this.statusCache.set(url, { online: false, checkedAt: Date.now() });
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
} catch {
this.statusCache.set(url, { online: false, checkedAt: Date.now() });
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
}
@ -166,14 +220,18 @@ class ServerManager {
const removed = this._loadRemoved();
// 4. Merge: union by URL, filtering out locally-removed servers
const localUrls = new Set(this.servers.map(s => s.url));
const remoteUrls = new Set(remoteServers.map(s => s.url));
const localUrls = new Set(this.servers.map(s => this._normalizeUrl(s.url)));
const remoteUrls = new Set(remoteServers.map(s => this._normalizeUrl(s.url)));
let changed = false;
// Add remote servers we don't have locally (and haven't removed)
for (const rs of remoteServers) {
if (!localUrls.has(rs.url) && !removed.has(rs.url)) {
const normalizedUrl = this._normalizeUrl(rs.url);
if (!localUrls.has(rs.url) && !localUrls.has(normalizedUrl)
&& !removed.has(rs.url) && !removed.has(normalizedUrl)) {
rs.url = normalizedUrl; // store the normalized form
this.servers.push(rs);
localUrls.add(normalizedUrl); // prevent duplicate adds within same sync
changed = true;
}
}
@ -264,6 +322,25 @@ class ServerManager {
return bytes;
}
/** Normalize a Haven server URL to its base path (strips /app(.html), query, hash, trailing slash). */
_normalizeUrl(url) {
url = String(url || '').trim();
if (!url) return '';
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
try {
const parsed = new URL(url);
parsed.hash = '';
parsed.search = '';
let pathname = parsed.pathname || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
pathname = pathname.replace(/\/app(?:\.html)?$/i, '') || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
return pathname === '/' ? parsed.origin : parsed.origin + pathname;
} catch {
return url.replace(/\/+$/, '');
}
}
// ── Removed-servers tracking (local-only) ─────────────
_loadRemoved() {
@ -277,8 +354,9 @@ class ServerManager {
}
markRemoved(url) {
const normalizedUrl = this._normalizeUrl(url);
const removed = this._loadRemoved();
removed.add(url);
if (normalizedUrl) removed.add(normalizedUrl);
this._saveRemoved(removed);
}
}

View file

@ -226,6 +226,15 @@ class VoiceManager {
if (this.onAfkMove) this.onAfkMove(data.channelCode);
});
// Kicked from voice because user joined from another client/tab
this.socket.on('voice-kicked', (data) => {
if (!data || !data.channelCode) return;
// Only act if we're currently in the channel we got kicked from
if (this.currentChannel !== data.channelCode) return;
this.leave();
if (this.onVoiceKicked) this.onVoiceKicked(data.channelCode, data.reason);
});
// Someone started screen sharing
this.socket.on('screen-share-started', (data) => {
this.screenSharers.add(data.userId);

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Reagieren",
"reply": "Antworten",
"quote": "Zitieren",
"pin": "Anheften",
"unpin": "Lösen",
"edit": "Bearbeiten",

View file

@ -158,6 +158,9 @@
"bio_updated": "Bio updated",
"display_name_changed": "Display name changed to \"{{name}}\"",
"channel_code_copied": "Channel code copied!",
"channel_link_copied": "Channel link copied",
"message_link_copied": "Message link copied",
"channel_link_unavailable": "Channel not available on this server",
"channel_muted": "Channel muted",
"channel_unmuted": "Channel unmuted",
"left_channel": "Left #{{name}}",
@ -201,6 +204,8 @@
"msg_toolbar": {
"react": "React",
"reply": "Reply",
"quote": "Quote",
"copy_link": "Copy link to message",
"pin": "Pin",
"unpin": "Unpin",
"edit": "Edit",
@ -1481,6 +1486,7 @@
"channel": {
"mute": "Mute Channel",
"unmute": "Unmute Channel",
"copy_link": "Copy Channel Link",
"join_voice": "Join Voice",
"disconnect_voice": "Disconnect Voice",
"leave": "Leave Channel",
@ -1497,6 +1503,7 @@
"dm": {
"mute": "Mute DM",
"unmute": "Unmute DM",
"copy_link": "Copy DM Link",
"delete": "Delete DM"
}
},

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Reaccionar",
"reply": "Responder",
"quote": "Citar",
"pin": "Fijar",
"unpin": "Desfijar",
"edit": "Editar",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Réagir",
"reply": "Répondre",
"quote": "Citer",
"pin": "Épingler",
"unpin": "Désépingler",
"edit": "Modifier",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Реакция",
"reply": "Ответить",
"quote": "Цитировать",
"pin": "Закрепить",
"unpin": "Открепить",
"edit": "Изменить",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "回应",
"reply": "回复",
"quote": "引用",
"pin": "置顶",
"unpin": "取消置顶",
"edit": "编辑",

350
server.js
View file

@ -114,6 +114,7 @@ app.use(helmet({
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'self'"], // allow mobile app iframe, block third-party clickjacking
...(process.env.FORCE_HTTP?.toLowerCase() === 'true' ? { upgradeInsecureRequests: null } : {}), // helmet 8.x auto-appends upgrade-insecure-requests; disable when FORCE_HTTP=true
}
},
crossOriginEmbedderPolicy: false, // needed for WebRTC
@ -158,7 +159,17 @@ app.use('/uploads', express.static(UPLOADS_DIR, {
setHeaders: (res, filePath) => {
// Force download for non-image files (prevents HTML/SVG execution in browser)
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
// Allow cross-origin access for images (needed for server icon pulling).
// CORP override is required because helmet defaults to 'same-origin', which
// would otherwise block cross-origin <img> loads even with ACAO set.
// Vary: Origin prevents a non-CORS cached response from being reused for a
// CORS request (which is what causes the "No 'Access-Control-Allow-Origin'
// header is present" error on a cached image).
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Vary', 'Origin');
} else {
res.setHeader('Content-Disposition', 'attachment');
}
}
@ -535,7 +546,12 @@ app.get('/', (req, res) => {
app.get('/app', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'public', 'app.html'));
// Inject current version into cache-busting query strings so client
// assets are never served stale after an update (especially in Electron).
const ver = require('./package.json').version;
let html = fs.readFileSync(path.join(__dirname, 'public', 'app.html'), 'utf8');
html = html.replace(/(\?v=)[^"']*/g, `$1${ver}`);
res.type('html').send(html);
});
// ── Vanity invite link (/invite/:code) ────────────────
@ -580,8 +596,11 @@ app.get('/api/donors', (req, res) => {
// ── Health check (CORS allowed for multi-server status pings) ──
app.get('/api/health', (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
res.set('Vary', 'Origin');
let name = process.env.SERVER_NAME || 'Haven';
let icon = null;
let fingerprint = null;
try {
const { getDb } = require('./src/database');
const db = getDb();
@ -589,11 +608,14 @@ app.get('/api/health', (req, res) => {
if (row && row.value) name = row.value;
const iconRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_icon'").get();
if (iconRow && iconRow.value) icon = iconRow.value;
const fpRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_fingerprint'").get();
if (fpRow && fpRow.value) fingerprint = fpRow.value;
} catch {}
res.json({
status: 'online',
name,
icon
icon,
fingerprint
// version intentionally omitted — don't fingerprint the server for attackers
});
});
@ -1168,6 +1190,245 @@ app.post('/api/upload-role-icon', uploadLimiter, (req, res) => {
});
});
// ── Admin: Server backup download (admin only) ──
// Configurable per-section via ?include=channels,users,settings,messages,files
// Backwards-compat: ?mode=structure → channels,users,settings ;
// ?mode=full → channels,users,settings,messages,files
// Token may be passed via ?token=... so the browser can trigger a normal download.
const ALL_BACKUP_SECTIONS = ['channels', 'users', 'settings', 'messages', 'files'];
app.get('/api/admin/backup', (req, res) => {
const token = req.query.token || req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
const AdmZip = require('adm-zip');
// Resolve which sections to include
let include = [];
if (typeof req.query.include === 'string' && req.query.include.trim()) {
include = req.query.include.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => ALL_BACKUP_SECTIONS.includes(s));
} else if (req.query.mode === 'full') {
include = ALL_BACKUP_SECTIONS.slice();
} else {
// default / mode=structure
include = ['channels', 'users', 'settings'];
}
if (!include.length) {
return res.status(400).json({ error: 'Pick at least one section to back up' });
}
const has = (s) => include.includes(s);
// The restore endpoint only accepts mode='full' — set it when the backup
// contains both the live DB and uploads, since that's what restore requires.
const mode = (has('messages') && has('files')) ? 'full' : 'partial';
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `haven-backup-${mode === 'full' ? 'full' : include.join('-')}-${ts}.zip`;
let tmpDb = null;
try {
const { getDb } = require('./src/database');
const db = getDb();
const zip = new AdmZip();
const manifest = {
app: 'haven',
version: require('./package.json').version,
exportedAt: new Date().toISOString(),
mode,
include,
serverName: process.env.SERVER_NAME || 'Haven',
};
zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2)));
// Structure JSON — collect tables per selected sections
const structureTables = [];
if (has('channels')) structureTables.push('channels', 'roles', 'role_permissions', 'user_roles', 'channel_members');
if (has('users')) structureTables.push('users');
if (has('settings')) structureTables.push('server_settings', 'whitelist');
if (structureTables.length) {
const data = {};
for (const tbl of structureTables) {
try { data[tbl] = db.prepare(`SELECT * FROM ${tbl}`).all(); }
catch { data[tbl] = []; }
}
// Strip secrets from users — passwords, TOTP, recovery codes never leave the server
if (data.users) {
data.users = data.users.map(u => {
const safe = { ...u };
delete safe.password_hash;
delete safe.password_version;
delete safe.totp_secret;
delete safe.totp_backup_codes;
delete safe.recovery_codes_hash;
delete safe.recovery_codes;
delete safe.email;
return safe;
});
}
// Strip vanity codes / invite codes from server_settings
if (data.server_settings) {
const SENSITIVE_KEYS = new Set(['vanity_code', 'server_invite_code']);
data.server_settings = data.server_settings.filter(r => !SENSITIVE_KEYS.has(r.key));
}
zip.addFile('structure.json', Buffer.from(JSON.stringify(data, null, 2)));
}
// Messages — include the full DB snapshot so restore can rebuild everything.
// (Cherry-picking message tables would defeat referential integrity.)
if (has('messages')) {
tmpDb = path.join(DATA_DIR, `.backup-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch {}
const safePath = tmpDb.replace(/'/g, "''");
db.prepare(`VACUUM INTO '${safePath}'`).run();
zip.addLocalFile(tmpDb, '', 'haven.db');
}
// Files — uploaded attachments / icons / banners / sounds
if (has('files') && fs.existsSync(UPLOADS_DIR)) {
const walk = (dir, rel) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'deleted-attachments') continue;
const full = path.join(dir, entry.name);
const sub = rel ? `${rel}/${entry.name}` : entry.name;
try {
if (entry.isFile()) zip.addLocalFile(full, `uploads${rel ? '/' + rel : ''}`);
else if (entry.isDirectory()) walk(full, sub);
} catch {}
}
};
walk(UPLOADS_DIR, '');
}
const buf = zip.toBuffer();
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buf);
} catch (err) {
console.error('[Backup] Failed:', err);
if (!res.headersSent) res.status(500).json({ error: 'Backup failed: ' + err.message });
} finally {
if (tmpDb) { try { fs.unlinkSync(tmpDb); } catch {} }
}
});
// ── Admin: Server backup restore (admin only, full backups only) ──
// Stages the uploaded backup, then schedules a process exit so the
// supervisor (Docker / systemd / installer service) restarts the server
// with the restored DB and uploads in place. The pre-restore data is
// preserved at haven.db.pre-restore / uploads.pre-restore for one cycle.
const restoreUpload = multer({
dest: path.join(DATA_DIR, 'tmp-restore'),
limits: { fileSize: 4 * 1024 * 1024 * 1024 },
});
app.post('/api/admin/restore', (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 (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
const tmpDir = path.join(DATA_DIR, 'tmp-restore');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
restoreUpload.single('backup')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
const cleanupTmp = () => { try { fs.unlinkSync(req.file.path); } catch {} };
try {
const AdmZip = require('adm-zip');
const zip = new AdmZip(req.file.path);
const entries = zip.getEntries();
const manifestEntry = entries.find(e => e.entryName === 'manifest.json');
if (!manifestEntry) {
cleanupTmp();
return res.status(400).json({ error: 'Invalid backup: missing manifest.json' });
}
let manifest;
try { manifest = JSON.parse(manifestEntry.getData().toString('utf8')); }
catch {
cleanupTmp();
return res.status(400).json({ error: 'Invalid backup: corrupt manifest.json' });
}
if (manifest.app !== 'haven') {
cleanupTmp();
return res.status(400).json({ error: 'Not a Haven backup file' });
}
if (manifest.mode !== 'full') {
cleanupTmp();
return res.status(400).json({
error: 'Only full backups can be restored automatically. Structure-only backups must be re-imported manually.',
});
}
const dbEntry = entries.find(e => e.entryName === 'haven.db');
if (!dbEntry) {
cleanupTmp();
return res.status(400).json({ error: 'Invalid full backup: missing haven.db' });
}
// Stage DB
const stagedDb = DB_PATH + '.restore';
fs.writeFileSync(stagedDb, dbEntry.getData());
// Stage uploads
const stagedUploads = UPLOADS_DIR + '.restore';
if (fs.existsSync(stagedUploads)) {
fs.rmSync(stagedUploads, { recursive: true, force: true });
}
const uploadEntries = entries.filter(e => e.entryName.startsWith('uploads/') && !e.isDirectory);
if (uploadEntries.length > 0) {
fs.mkdirSync(stagedUploads, { recursive: true });
for (const ue of uploadEntries) {
const rel = ue.entryName.slice('uploads/'.length);
if (!rel || rel.includes('..')) continue;
const dest = path.join(stagedUploads, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, ue.getData());
}
}
cleanupTmp();
res.json({
ok: true,
message: 'Backup staged. Server will restart in ~2 seconds to apply. If the server does not come back up, your hosting setup may not auto-restart — start Haven manually.',
scheduled: true,
});
// Apply swap and exit so the supervisor restarts us cleanly
setTimeout(() => {
console.log('🔄 Applying staged backup restore and restarting...');
try {
if (fs.existsSync(stagedDb)) {
try { fs.copyFileSync(DB_PATH, DB_PATH + '.pre-restore'); } catch {}
// Remove stale WAL/SHM so SQLite reopens against the restored file
try { fs.unlinkSync(DB_PATH + '-wal'); } catch {}
try { fs.unlinkSync(DB_PATH + '-shm'); } catch {}
fs.renameSync(stagedDb, DB_PATH);
}
if (fs.existsSync(stagedUploads)) {
const oldUploads = UPLOADS_DIR + '.pre-restore';
if (fs.existsSync(oldUploads)) fs.rmSync(oldUploads, { recursive: true, force: true });
if (fs.existsSync(UPLOADS_DIR)) fs.renameSync(UPLOADS_DIR, oldUploads);
fs.renameSync(stagedUploads, UPLOADS_DIR);
}
} catch (e) {
console.error('[Restore] Swap failed:', e);
}
process.exit(0);
}, 1500);
} catch (e) {
cleanupTmp();
console.error('[Restore] Failed:', e);
if (!res.headersSent) res.status(500).json({ error: 'Restore failed: ' + e.message });
}
});
});
// ── Server banner upload (admin only, image only, max 4 MB) ──
app.post('/api/upload-server-banner', uploadLimiter, (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
@ -1784,6 +2045,89 @@ app.post('/api/webhooks/:token', webhookLimiter, express.json({ limit: '64kb' })
res.status(200).json({ success: true, message_id: result.lastInsertRowid });
});
// ── Bot: Delete a message in the webhook's channel ──────
app.delete('/api/webhooks/:token/messages/:messageId', webhookLimiter, (req, res) => {
const { getDb } = require('./src/database');
const db = getDb();
const { token, messageId } = req.params;
const webhook = getWebhookByToken(token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const mid = parseInt(messageId, 10);
if (!Number.isInteger(mid) || mid < 1) return res.status(400).json({ error: 'Invalid message ID' });
const msg = db.prepare('SELECT id, content, channel_id FROM messages WHERE id = ? AND channel_id = ?').get(mid, webhook.channel_id);
if (!msg) return res.status(404).json({ error: 'Message not found in this channel' });
try {
db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(mid);
db.prepare('DELETE FROM reactions WHERE message_id = ?').run(mid);
db.prepare('DELETE FROM messages WHERE id = ?').run(mid);
} catch (err) {
console.error('Bot delete message error:', err);
return res.status(500).json({ error: 'Failed to delete message' });
}
// Move any uploaded attachments to the deleted folder
const uploadRe = /\/uploads\/((?!deleted-attachments)[\w\-.]+)/g;
let m;
while ((m = uploadRe.exec(msg.content || '')) !== null) {
const src = path.join(uploadDir, m[1]);
const dst = path.join(DELETED_ATTACHMENTS_DIR, m[1]);
if (fs.existsSync(src)) {
try { fs.renameSync(src, dst); } catch { /* file locked or already moved */ }
}
}
// Find channel code for broadcasting
const channel = db.prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id);
if (channel && io) {
io.to(`channel:${channel.code}`).emit('message-deleted', {
channelCode: channel.code,
messageId: mid
});
}
res.json({ success: true });
});
// ── Bot: Play a soundboard sound in the webhook's channel ──
app.post('/api/webhooks/:token/sounds', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => {
const webhook = getWebhookByToken(req.params.token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const soundName = typeof req.body.sound === 'string' ? req.body.sound.trim() : '';
if (!soundName) return res.status(400).json({ error: 'sound name required' });
// Verify the sound exists
const { getDb } = require('./src/database');
const builtin = BUILTIN_SOUNDS.find(s => s.name === soundName);
let soundUrl;
if (builtin) {
soundUrl = builtin.url;
} else {
const custom = getDb().prepare('SELECT filename FROM custom_sounds WHERE name = ?').get(soundName);
if (!custom) return res.status(404).json({ error: 'Sound not found' });
soundUrl = `/uploads/${custom.filename}`;
}
// Find the channel code and broadcast the sound event
const channel = getDb().prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (io) {
io.to(`channel:${channel.code}`).emit('play-sound', {
channelCode: channel.code,
soundUrl,
soundName,
botName: webhook.name
});
}
res.json({ success: true });
});
// ═══════════════════════════════════════════════════════════
// MODERATION REST API
// ═══════════════════════════════════════════════════════════

View file

@ -351,6 +351,23 @@ router.post('/login', async (req, res) => {
}
});
// ── Validate token (lightweight, for SSO consent page) ───
router.get('/validate', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = token ? verifyToken(token) : null;
if (!decoded) return res.status(401).json({ error: 'Invalid token' });
const db = getDb();
const user = db.prepare('SELECT username, display_name, avatar FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
username: user.username,
displayName: user.display_name || user.username,
avatar: user.avatar || null
});
});
// ── TOTP Validate (second step of login) ─────────────────
router.post('/totp/validate', async (req, res) => {
try {
@ -1019,12 +1036,16 @@ router.get('/SSO', (req, res) => {
.success { display: none; color: #4ade80; font-size: 15px; margin-top: 12px; }
.not-logged-in { color: #ef4444; }
.loading { color: #888; }
.debug { margin-top: 10px; font-size: 12px; color: #8f95b2; word-break: break-word; }
.debug.error { color: #ef4444; }
.debug.ok { color: #4ade80; }
</style>
</head>
<body>
<div class="card">
<h2> Haven SSO</h2>
<div id="loading" class="loading"><p>Checking login status...</p></div>
<div id="sso-debug" class="debug">Starting SSO checks...</div>
<div id="not-logged-in" style="display:none">
<p class="not-logged-in">You are not logged in to this server.</p>
<p style="font-size:13px;color:#888;margin-top:8px">Log in first, then try again.</p>
@ -1048,31 +1069,128 @@ router.get('/SSO', (req, res) => {
<script>
const authCode = '${safeAuthCode}';
const origin = '${safeOrigin}';
let approvedProfile = null;
(async function() {
const token = localStorage.getItem('haven_token');
if (!token) {
const loadingEl = document.getElementById('loading');
const debugEl = document.getElementById('sso-debug');
function setDebug(msg, tone = '') {
if (!debugEl) return;
debugEl.textContent = msg;
debugEl.className = 'debug' + (tone ? (' ' + tone) : '');
}
function showNotLoggedIn(reason = 'No active login was found on this server.') {
document.getElementById('loading').style.display = 'none';
document.getElementById('not-logged-in').style.display = 'block';
setDebug(reason, 'error');
}
function showConsentReady() {
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
setDebug('Login verified. You can approve this SSO request.', 'ok');
}
// Safety watchdog: if anything stalls, stop showing an indefinite spinner.
const bootTimeout = setTimeout(() => {
if (loadingEl && loadingEl.style.display !== 'none') {
// If we have a cached user profile, use that instead of failing — server
// may simply be slow/unreachable for the validate endpoint, but the
// profile we'll share is already cached locally.
try {
const cachedRaw = localStorage.getItem('haven_user');
const cached = cachedRaw ? JSON.parse(cachedRaw) : null;
if (cached && cached.username) {
approvedProfile = {
username: cached.username,
displayName: cached.displayName || cached.username,
profilePicture: cached.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = cached.avatar ? 'Will be shared' : 'None set';
showConsentReady();
setDebug('Using cached profile (validate endpoint did not respond in time).', 'ok');
return;
}
} catch {}
showNotLoggedIn('SSO check timed out. Try refreshing this page or logging in again.');
}
}, 5000);
let token;
try {
setDebug('Reading local login token...');
token = localStorage.getItem('haven_token');
} catch {
// localStorage blocked (third-party cookies, popup restrictions, etc.)
showNotLoggedIn('Browser storage is blocked in this tab, so Haven cannot read your login token.');
clearTimeout(bootTimeout);
return;
}
// Verify token and get user info
try {
const userStr = localStorage.getItem('haven_user');
const user = userStr ? JSON.parse(userStr) : null;
if (!user) throw new Error('No user data');
document.getElementById('sso-username').textContent = user.displayName || user.username || '—';
document.getElementById('sso-avatar').textContent = user.avatar ? 'Will be shared' : 'None set';
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
} catch {
document.getElementById('loading').style.display = 'none';
document.getElementById('not-logged-in').style.display = 'block';
if (!token) {
showNotLoggedIn('No Haven login token found in this browser profile.');
clearTimeout(bootTimeout);
return;
}
// Verify token is still valid by calling the server
try {
setDebug('Validating token with this server...');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
const verifyRes = await fetch('/api/auth/validate', {
headers: { 'Authorization': 'Bearer ' + token },
signal: ctrl.signal
});
clearTimeout(timer);
if (!verifyRes.ok) {
showNotLoggedIn('Token validation failed (' + verifyRes.status + '). Please log in again.');
clearTimeout(bootTimeout);
return;
}
const userData = await verifyRes.json();
approvedProfile = {
username: userData.username || '—',
displayName: userData.displayName || userData.username || '—',
profilePicture: userData.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = userData.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
// Fall back to localStorage user data if validate endpoint unavailable
try {
setDebug('Validate endpoint unavailable. Falling back to local profile data...');
const userStr = localStorage.getItem('haven_user');
const user = userStr ? JSON.parse(userStr) : null;
if (!user) {
showNotLoggedIn('Could not validate token and no cached local user profile was found.');
clearTimeout(bootTimeout);
return;
}
approvedProfile = {
username: user.username || '—',
displayName: user.displayName || user.username || '—',
profilePicture: user.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = user.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
showNotLoggedIn('Failed to read cached local profile for SSO consent.');
clearTimeout(bootTimeout);
return;
}
}
document.getElementById('approve-btn').addEventListener('click', async () => {
const btn = document.getElementById('approve-btn');
btn.disabled = true;
@ -1084,15 +1202,28 @@ router.get('/SSO', (req, res) => {
body: JSON.stringify({ authCode, origin })
});
if (res.ok) {
setDebug('Approval stored on home server. Returning profile to requesting server...', 'ok');
if (origin && window.opener && approvedProfile) {
try {
window.opener.postMessage({
type: 'haven-sso-approved',
authCode,
profile: approvedProfile,
serverOrigin: window.location.origin
}, origin);
} catch {}
}
document.getElementById('buttons').style.display = 'none';
document.getElementById('success-msg').style.display = 'block';
} else {
const data = await res.json().catch(() => ({}));
setDebug(data.error || 'Approval failed on home server.', 'error');
alert(data.error || 'Failed to approve');
btn.disabled = false;
btn.textContent = 'Approve';
}
} catch {
setDebug('Connection error while approving SSO request.', 'error');
alert('Connection error');
btn.disabled = false;
btn.textContent = 'Approve';
@ -1132,6 +1263,13 @@ router.post('/SSO/approve', (req, res) => {
// GET /api/auth/SSO/authenticate?authCode=X — Foreign server calls this to retrieve user info
// This is called by the CLIENT on the foreign server, not server-to-server.
router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
const requestOrigin = req.headers.origin;
if (requestOrigin) {
res.set('Access-Control-Allow-Origin', requestOrigin);
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Credentials', 'false');
}
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
if (!authCode) return res.status(400).json({ error: 'Missing auth code' });
@ -1141,11 +1279,8 @@ router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
// One-time use: delete immediately
pendingSSO.delete(authCode);
// Set CORS to allow the requesting origin (if provided during approval)
if (pending.origin) {
res.set('Access-Control-Allow-Origin', pending.origin);
res.set('Access-Control-Allow-Credentials', 'false');
}
// If this auth code was issued for a specific origin, mirror it for strictness.
if (pending.origin) res.set('Access-Control-Allow-Origin', pending.origin);
const db = getDb();
const user = db.prepare('SELECT username, avatar, display_name FROM users WHERE id = ?').get(pending.userId);
@ -1159,7 +1294,8 @@ router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
}
res.json({
username: user.display_name || user.username,
username: user.username,
displayName: user.display_name || user.username,
profilePicture: avatarUrl
});
});

View file

@ -210,6 +210,10 @@ function initDatabase() {
insertSetting.run('setup_wizard_complete', 'false'); // first-time admin setup wizard
insertSetting.run('update_banner_admin_only', 'false'); // hide update banner from non-admins
// Unique server fingerprint — used by the multi-server sidebar to detect "self"
const crypto = require('crypto');
insertSetting.run('server_fingerprint', crypto.randomUUID());
// ── Migration: pinned_messages table ──────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS pinned_messages (
@ -803,6 +807,14 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_bot_commands_webhook ON bot_commands(webhook_id);
`);
// ── Migration: chat threads (thread_id on messages) ─────
try {
db.prepare("SELECT thread_id FROM messages LIMIT 0").get();
} catch {
db.exec("ALTER TABLE messages ADD COLUMN thread_id INTEGER DEFAULT NULL REFERENCES messages(id) ON DELETE CASCADE");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id) WHERE thread_id IS NOT NULL");
return db;
}

View file

@ -872,6 +872,14 @@ module.exports = function register(socket, ctx) {
if (!category) category = null;
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');
// Case-insensitive dedup: if another channel already uses this tag with
// different casing, adopt the existing casing so they group together.
if (category) {
const existing = db.prepare(
'SELECT category FROM channels WHERE category IS NOT NULL AND id != ? AND category COLLATE NOCASE = ?'
).get(channel.id, category);
if (existing) category = existing.category;
}
try {
db.prepare('UPDATE channels SET category = ? WHERE id = ?').run(category, channel.id);
broadcastChannelLists();

View file

@ -492,7 +492,18 @@ function setupSocketHandlers(io, db) {
// ── handleVoiceLeave ────────────────────────────────────
function handleVoiceLeave(socket, code) {
const voiceRoom = voiceUsers.get(code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
if (!voiceRoom) return;
const entry = voiceRoom.get(socket.user.id);
if (!entry) return;
// If the stored entry belongs to a different socket (e.g. the user joined
// from a second client which kicked this one), don't touch the map — just
// remove this stale socket from the room and return.
if (entry.socketId !== socket.id) {
socket.leave(`voice:${code}`);
return;
}
voiceRoom.delete(socket.user.id);
socket.leave(`voice:${code}`);

View file

@ -34,7 +34,7 @@ module.exports = function register(socket, ctx) {
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived, m.poll_data,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.id < ?
WHERE m.channel_id = ? AND m.id < ? AND m.thread_id IS NULL
ORDER BY m.created_at DESC LIMIT ?
`).all(channel.id, before, limit);
} else if (after) {
@ -42,7 +42,7 @@ module.exports = function register(socket, ctx) {
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived, m.poll_data,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.id > ?
WHERE m.channel_id = ? AND m.id > ? AND m.thread_id IS NULL
ORDER BY m.created_at ASC LIMIT ?
`).all(channel.id, after, limit);
} else if (around) {
@ -51,7 +51,7 @@ module.exports = function register(socket, ctx) {
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived, m.poll_data,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.id < ?
WHERE m.channel_id = ? AND m.id < ? AND m.thread_id IS NULL
ORDER BY m.created_at DESC LIMIT ?
`).all(channel.id, around, half);
const targetMsg = db.prepare(`
@ -64,7 +64,7 @@ module.exports = function register(socket, ctx) {
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived, m.poll_data,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.id > ?
WHERE m.channel_id = ? AND m.id > ? AND m.thread_id IS NULL
ORDER BY m.created_at ASC LIMIT ?
`).all(channel.id, around, half);
// Combine: beforeMsgs is DESC so reverse it, target, then afterMsgs ASC
@ -74,7 +74,7 @@ module.exports = function register(socket, ctx) {
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived, m.poll_data,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ?
WHERE m.channel_id = ? AND m.thread_id IS NULL
ORDER BY m.created_at DESC LIMIT ?
`).all(channel.id, limit);
}
@ -89,8 +89,8 @@ module.exports = function register(socket, ctx) {
db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.id IN (${ph})
`).all(...replyIds).forEach(r => replyMap.set(r.id, r));
WHERE m.id IN (${ph}) AND m.channel_id = ?
`).all(...replyIds, channel.id).forEach(r => replyMap.set(r.id, r));
}
const reactionMap = new Map();
@ -136,6 +136,41 @@ module.exports = function register(socket, ctx) {
});
}
// ── Thread metadata enrichment ─────────────────────────
const threadMap = new Map();
if (msgIds.length > 0) {
const ph = msgIds.map(() => '?').join(',');
// Get thread counts and last activity for messages that are thread parents
db.prepare(`
SELECT thread_id,
COUNT(*) as reply_count,
MAX(created_at) as last_reply_at
FROM messages WHERE thread_id IN (${ph})
GROUP BY thread_id
`).all(...msgIds).forEach(t => {
threadMap.set(t.thread_id, { count: t.reply_count, lastReplyAt: utcStamp(t.last_reply_at), participants: [] });
});
// Get participants for threads (up to 5 unique usernames)
if (threadMap.size > 0) {
const threadIds = [...threadMap.keys()];
const tph = threadIds.map(() => '?').join(',');
db.prepare(`
SELECT tm.thread_id, COALESCE(u.display_name, u.username) as username, u.avatar
FROM (
SELECT thread_id, user_id, MAX(created_at) as latest
FROM messages WHERE thread_id IN (${tph})
GROUP BY thread_id, user_id
) tm JOIN users u ON tm.user_id = u.id
ORDER BY tm.latest DESC
`).all(...threadIds).forEach(p => {
const info = threadMap.get(p.thread_id);
if (info && info.participants.length < 5) {
info.participants.push({ username: p.username, avatar: p.avatar });
}
});
}
}
const enriched = messages.map(m => {
const obj = { ...m };
if (obj.created_at && !obj.created_at.endsWith('Z')) obj.created_at = utcStamp(obj.created_at);
@ -144,6 +179,7 @@ module.exports = function register(socket, ctx) {
obj.reactions = reactionMap.get(m.id) || [];
obj.pinned = pinnedSet ? pinnedSet.has(m.id) : false;
obj.is_archived = !!m.is_archived;
obj.thread = threadMap.get(m.id) || null;
if (m.poll_data) {
try {
obj.poll = JSON.parse(m.poll_data);
@ -170,9 +206,17 @@ module.exports = function register(socket, ctx) {
return obj;
});
// Include the user's last-read position so the client can show a
// "NEW MESSAGES" divider between read and unread messages.
const readPos = db.prepare(
'SELECT last_read_message_id FROM read_positions WHERE user_id = ? AND channel_id = ?'
).get(socket.user.id, channel.id);
const lastReadMessageId = readPos ? readPos.last_read_message_id : 0;
socket.emit('message-history', {
channelCode: code,
messages: (after || around) ? enriched : enriched.reverse(),
lastReadMessageId,
...(around ? { around } : {})
});
});
@ -365,7 +409,8 @@ module.exports = function register(socket, ctx) {
reply_to: null,
replyContext: null,
reactions: [],
edited_at: null
edited_at: null,
thread: null
};
if (slashResult.tts) message.tts = true;
@ -384,10 +429,16 @@ module.exports = function register(socket, ctx) {
}
}
const replyTo = isInt(data.replyTo) ? data.replyTo : null;
let replyTo = isInt(data.replyTo) ? data.replyTo : null;
const safeContent = sanitizeText(content.trim());
if (!safeContent) return;
// Validate replyTo belongs to same channel (prevents cross-channel data leaks)
if (replyTo) {
const replyMsg = db.prepare('SELECT channel_id FROM messages WHERE id = ?').get(replyTo);
if (!replyMsg || replyMsg.channel_id !== channel.id) replyTo = null;
}
try {
const result = db.prepare(
'INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?)'
@ -404,14 +455,15 @@ module.exports = function register(socket, ctx) {
reply_to: replyTo,
replyContext: null,
reactions: [],
edited_at: null
edited_at: null,
thread: null
};
if (replyTo) {
message.replyContext = db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username FROM messages m
LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(replyTo) || null;
LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ? AND m.channel_id = ?
`).get(replyTo, channel.id) || null;
}
io.to(`channel:${code}`).emit('new-message', { channelCode: code, message });
@ -940,6 +992,7 @@ module.exports = function register(socket, ctx) {
replyContext: null,
reactions: [],
edited_at: null,
thread: null,
poll: { question: safeQuestion, options: cleanOptions, multiVote, anonymous, votes: {}, totalVotes: 0 }
};
cleanOptions.forEach((_, i) => { message.poll.votes[i] = []; });
@ -1105,4 +1158,181 @@ module.exports = function register(socket, ctx) {
console.error('Mark read channel error:', err);
}
});
// ═══════════════════════════════════════════════════════
// THREADS
// ═══════════════════════════════════════════════════════
// ── Get thread messages ─────────────────────────────────
socket.on('get-thread-messages', (data) => {
if (!data || typeof data !== 'object') return;
const parentId = isInt(data.parentId) ? data.parentId : null;
if (!parentId) return;
const code = socket.currentChannel;
if (!code) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
if (!channel) return;
// Verify parent message belongs to this channel and fetch OP metadata
const parent = db.prepare(`
SELECT m.id,
m.content,
m.created_at,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username,
COALESCE(m.webhook_avatar, u.avatar) as avatar,
COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m
LEFT JOIN users u ON m.user_id = u.id
WHERE m.id = ? AND m.channel_id = ?
`).get(parentId, channel.id);
if (!parent) return;
const messages = db.prepare(`
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ?
ORDER BY m.created_at ASC
`).all(parentId);
// Enrich with reactions and reply context
const msgIds = messages.map(m => m.id);
const replyIds = [...new Set(messages.filter(m => m.reply_to).map(m => m.reply_to))];
const replyMap = new Map();
if (replyIds.length > 0) {
const ph = replyIds.map(() => '?').join(',');
db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id IN (${ph})
`).all(...replyIds).forEach(r => replyMap.set(r.id, r));
}
const reactionMap = new Map();
if (msgIds.length > 0) {
const ph = msgIds.map(() => '?').join(',');
db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, COALESCE(u.display_name, u.username) as username
FROM reactions r JOIN users u ON r.user_id = u.id WHERE r.message_id IN (${ph}) ORDER BY r.id
`).all(...msgIds).forEach(r => {
if (!reactionMap.has(r.message_id)) reactionMap.set(r.message_id, []);
reactionMap.get(r.message_id).push({ emoji: r.emoji, user_id: r.user_id, username: r.username });
});
}
const enriched = messages.map(m => {
const obj = { ...m };
if (obj.created_at && !obj.created_at.endsWith('Z')) obj.created_at = utcStamp(obj.created_at);
if (obj.edited_at && !obj.edited_at.endsWith('Z')) obj.edited_at = utcStamp(obj.edited_at);
obj.replyContext = m.reply_to ? (replyMap.get(m.reply_to) || null) : null;
obj.reactions = reactionMap.get(m.id) || [];
return obj;
});
socket.emit('thread-messages', {
parentId,
parentContent: parent.content,
parentUsername: parent.username || '[Deleted User]',
parentAvatar: parent.avatar || null,
parentAvatarShape: parent.avatar_shape || 'circle',
parentCreatedAt: utcStamp(parent.created_at),
messages: enriched
});
});
// ── Send message to thread ──────────────────────────────
socket.on('send-thread-message', (data, callback) => {
if (!data || typeof data !== 'object') return;
const parentId = isInt(data.parentId) ? data.parentId : null;
let content = typeof data.content === 'string' ? data.content.trim() : '';
if (!parentId || !content) return;
if (floodCheck('message')) return;
const code = socket.currentChannel;
if (!code) return;
const channel = db.prepare('SELECT id, is_dm FROM channels WHERE code = ?').get(code);
if (!channel) return;
// Verify parent message belongs to this channel and is not itself a thread message
const parent = db.prepare('SELECT id, thread_id, channel_id FROM messages WHERE id = ? AND channel_id = ?').get(parentId, channel.id);
if (!parent || parent.thread_id) return; // Can't create sub-threads
const safeContent = sanitizeText(content);
if (!safeContent) return;
let replyTo = isInt(data.replyTo) ? data.replyTo : null;
if (replyTo) {
const replyMsg = db.prepare('SELECT thread_id FROM messages WHERE id = ?').get(replyTo);
if (!replyMsg || replyMsg.thread_id !== parentId) replyTo = null;
}
try {
const result = db.prepare(
'INSERT INTO messages (channel_id, user_id, content, thread_id, reply_to) VALUES (?, ?, ?, ?, ?)'
).run(channel.id, socket.user.id, safeContent, parentId, replyTo);
const message = {
id: result.lastInsertRowid,
content: safeContent,
created_at: new Date().toISOString(),
username: socket.user.displayName,
user_id: socket.user.id,
avatar: socket.user.avatar || null,
avatar_shape: socket.user.avatar_shape || 'circle',
reply_to: replyTo,
replyContext: null,
reactions: [],
edited_at: null,
thread_id: parentId
};
if (replyTo) {
message.replyContext = db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(replyTo) || null;
}
// Emit to everyone in the channel who has the thread open
io.to(`channel:${code}`).emit('new-thread-message', {
channelCode: code,
parentId,
message
});
// Update thread preview on the parent message for all users
const threadCount = db.prepare('SELECT COUNT(*) as count FROM messages WHERE thread_id = ?').get(parentId);
const lastMsg = db.prepare(`
SELECT m.id, m.content, m.created_at, COALESCE(u.display_name, u.username) as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 1
`).get(parentId);
// Get up to 5 unique participants
const participants = db.prepare(`
SELECT DISTINCT COALESCE(u.display_name, u.username) as username, u.avatar
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 5
`).all(parentId);
io.to(`channel:${code}`).emit('thread-updated', {
channelCode: code,
parentId,
thread: {
count: threadCount.count,
lastReplyAt: lastMsg ? lastMsg.created_at : null,
participants: participants.map(p => ({ username: p.username, avatar: p.avatar }))
}
});
if (typeof callback === 'function') callback({ success: true });
} catch (err) {
console.error('send-thread-message error:', err.message);
if (typeof callback === 'function') callback({ error: 'Failed to send thread message' });
}
});
};

View file

@ -394,14 +394,27 @@ module.exports = function register(socket, ctx) {
try {
const row = db.prepare('SELECT encrypted_private_key, e2e_key_salt, public_key FROM users WHERE id = ?')
.get(socket.user.id);
const hasBackup = !!(row && row.encrypted_private_key && row.e2e_key_salt);
// Forward just the pub-key JWK (x,y) so clients can detect
// local-vs-server divergence without an extra round-trip. Additive:
// legacy clients ignore it.
let publicKey = null;
if (row && row.public_key) {
try {
const parsed = typeof row.public_key === 'string' ? JSON.parse(row.public_key) : row.public_key;
if (parsed && parsed.x && parsed.y) publicKey = { kty: parsed.kty, crv: parsed.crv, x: parsed.x, y: parsed.y };
} catch { /* stored pub key not JSON — skip */ }
}
socket.emit('encrypted-key-result', {
encryptedKey: row?.encrypted_private_key || null,
salt: row?.e2e_key_salt || null,
hasPublicKey: !!(row && row.public_key)
hasPublicKey: !!(row && row.public_key),
publicKey,
state: hasBackup ? 'present' : 'empty'
});
} catch (err) {
console.error('Get encrypted key error:', err);
socket.emit('encrypted-key-result', { encryptedKey: null, salt: null, hasPublicKey: false });
socket.emit('encrypted-key-result', { encryptedKey: null, salt: null, hasPublicKey: false, publicKey: null, state: 'error' });
}
});

View file

@ -73,15 +73,24 @@ module.exports = function register(socket, ctx) {
if (!voiceUsers.has(code)) voiceUsers.set(code, new Map());
// If this user is already in the same voice channel (e.g. from another
// client/tab), remove the old socket entry and leave the old room so
// we don't end up with duplicate voice connections.
// client/tab), do a full voice-leave on the old socket so peer connections,
// screen shares, and webcams are properly cleaned up. Then notify the old
// client so it resets its local voice UI.
const existingEntry = voiceUsers.get(code).get(socket.user.id);
if (existingEntry && existingEntry.socketId !== socket.id) {
const oldSocket = io.sockets.sockets.get(existingEntry.socketId);
if (oldSocket) oldSocket.leave(`voice:${code}`);
voiceUsers.get(code).delete(socket.user.id);
if (oldSocket) {
handleVoiceLeave(oldSocket, code);
oldSocket.emit('voice-kicked', { channelCode: code, reason: 'Joined from another client' });
} else {
// Stale entry — socket already disconnected; just clean up the map
voiceUsers.get(code).delete(socket.user.id);
}
}
// Re-create the map if handleVoiceLeave cleaned it up (last user left)
if (!voiceUsers.has(code)) voiceUsers.set(code, new Map());
socket.join(`voice:${code}`);
const existingUsers = Array.from(voiceUsers.get(code).values())
@ -177,9 +186,13 @@ module.exports = function register(socket, ctx) {
});
// ── WebRTC signaling ────────────────────────────────────
const MAX_SDP_SIZE = 16384; // 16 KB — generous limit for SDP offers/answers
const MAX_ICE_SIZE = 2048; // 2 KB — ICE candidates are small
socket.on('voice-offer', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.offer) return;
if (typeof data.offer !== 'object' || JSON.stringify(data.offer).length > MAX_SDP_SIZE) return;
if (!voiceUsers.get(data.code)?.has(socket.user.id)) return;
const target = voiceUsers.get(data.code)?.get(data.targetUserId);
if (target) {
@ -194,6 +207,7 @@ module.exports = function register(socket, ctx) {
socket.on('voice-answer', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.answer) return;
if (typeof data.answer !== 'object' || JSON.stringify(data.answer).length > MAX_SDP_SIZE) return;
if (!voiceUsers.get(data.code)?.has(socket.user.id)) return;
const target = voiceUsers.get(data.code)?.get(data.targetUserId);
if (target) {
@ -208,6 +222,7 @@ module.exports = function register(socket, ctx) {
socket.on('voice-ice-candidate', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId)) return;
if (data.candidate && (typeof data.candidate !== 'object' || JSON.stringify(data.candidate).length > MAX_ICE_SIZE)) return;
if (!voiceUsers.get(data.code)?.has(socket.user.id)) return;
const target = voiceUsers.get(data.code)?.get(data.targetUserId);
if (target) {

View file

@ -953,13 +953,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.2.0/Haven-Setup-1.2.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-Setup-1.3.0.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.2.0/Haven-1.2.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-1.3.0.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.2.0/haven-desktop_1.2.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/haven-desktop_1.3.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v3.2.0</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</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/v3.2.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.2.0 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,19 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v3.2.0</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.3.0</span> &mdash; Last read indicator, per-event volume sliders, server list sync improvements</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.3.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.2.0</span> &mdash; Mark as Read, pinned message jump, iOS Safari fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip">Download &rarr;</a>
</div>
<div class="version-item">