mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
Compare commits
39 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0d8f2006 | ||
|
|
9f2f4dfd74 | ||
|
|
feb357bd9f | ||
|
|
bb02f49526 | ||
|
|
0cacae73b3 | ||
|
|
dd8258d46b | ||
|
|
83fbfb5fd4 | ||
|
|
47454daead | ||
|
|
e59a414bb6 | ||
|
|
dd2df1727a | ||
|
|
e6a0caa0e7 | ||
|
|
8715392e50 | ||
|
|
0c2b4a8be4 | ||
|
|
17519d5a09 | ||
|
|
b56dff0535 | ||
|
|
76336b74fd | ||
|
|
f05a62d14e | ||
|
|
7f866838ce | ||
|
|
032b4aee6a | ||
|
|
28108dae56 | ||
|
|
923576a015 | ||
|
|
b964b1c23c | ||
|
|
c48534b6ae | ||
|
|
69b03592bb | ||
|
|
b02b9a9ff7 | ||
|
|
316494f877 | ||
|
|
fe5a17d178 | ||
|
|
93479775ae | ||
|
|
00c6689c89 | ||
|
|
5a02e41e49 | ||
|
|
a5df2d9b13 | ||
|
|
9f5da48976 | ||
|
|
564bae8750 | ||
|
|
e4db71dd09 | ||
|
|
0d1858f747 | ||
|
|
f2827690a0 | ||
|
|
8befde0b15 | ||
|
|
f5e94877f0 | ||
|
|
2d82cc697c |
39 changed files with 4020 additions and 277 deletions
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
60
CHANGELOG.md
60
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
87
GUIDE.md
87
GUIDE.md
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -953,13 +953,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & Linux</span>
|
||||
</div>
|
||||
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.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>⬡ Haven Server — v3.2.0</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.3.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.2.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
33
donor-order.json
Normal file
33
donor-order.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
166
public/app.html
166
public/app.html
|
|
@ -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">×</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">×</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 & 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 & 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">×</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>
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
179
public/js/e2e.js
179
public/js/e2e.js
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)>\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)((?:>[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
|
||||
const lines = block.trim().split('\n').map(line => line.replace(/^>\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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Reagieren",
|
||||
"reply": "Antworten",
|
||||
"quote": "Zitieren",
|
||||
"pin": "Anheften",
|
||||
"unpin": "Lösen",
|
||||
"edit": "Bearbeiten",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Reaccionar",
|
||||
"reply": "Responder",
|
||||
"quote": "Citar",
|
||||
"pin": "Fijar",
|
||||
"unpin": "Desfijar",
|
||||
"edit": "Editar",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Réagir",
|
||||
"reply": "Répondre",
|
||||
"quote": "Citer",
|
||||
"pin": "Épingler",
|
||||
"unpin": "Désépingler",
|
||||
"edit": "Modifier",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Реакция",
|
||||
"reply": "Ответить",
|
||||
"quote": "Цитировать",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить",
|
||||
"edit": "Изменить",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "回应",
|
||||
"reply": "回复",
|
||||
"quote": "引用",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"edit": "编辑",
|
||||
|
|
|
|||
350
server.js
350
server.js
|
|
@ -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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
|
|
|||
178
src/auth.js
178
src/auth.js
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -953,13 +953,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & Linux</span>
|
||||
</div>
|
||||
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.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>⬡ Haven Server — v3.2.0</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.3.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.2.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
Loading…
Reference in a new issue