feat: add threads, toolbar customization, and SSO/auth flow improvements

This commit is contained in:
ancsemi 2026-04-20 18:28:50 -04:00
parent 47454daead
commit 83fbfb5fd4
20 changed files with 2245 additions and 150 deletions

View file

@ -11,6 +11,26 @@ 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

View file

@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v3.4.0</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.4.0 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v3.4.0</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">

View file

@ -9,24 +9,24 @@
],
"donors": [
"Andrew Schott",
"Taylan",
"birdycrazy",
"Taylan",
"(,,•ᴗ•,,)",
"Morgan",
"deNully",
"wreckedcarzz",
"khyrna",
"HoppyGamers",
"khyrna",
"wreckedcarzz",
"c0urier",
"JollyOrc",
"Ezmana",
"john doe",
"JollyOrc",
"lataxd9",
"ohmygdala",
"Orange Lantern",
"lataxd9",
"MutantRabbbit767",
"haruna",
"CloneBtw",
"haruna",
"ArtyDaSmarty",
"6yBbBc"
]

View file

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

View file

@ -711,6 +711,37 @@
</div>
</div>
<!-- Thread Panel (slides in from right) -->
<div id="thread-panel" class="thread-panel" style="display:none">
<div class="thread-panel-resizer" id="thread-panel-resizer" aria-hidden="true"></div>
<div class="thread-panel-header">
<div class="thread-panel-header-top">
<span class="thread-panel-icon">🧵</span>
<span id="thread-panel-title" class="thread-panel-title">Thread</span>
<button id="thread-panel-pip" class="icon-btn small" title="Pop out thread (PiP)" aria-pressed="false"></button>
<button id="thread-panel-close" class="icon-btn small">&times;</button>
</div>
<div class="thread-parent-meta" id="thread-parent-meta">
<div class="thread-parent-avatar-wrap" id="thread-parent-avatar-wrap"></div>
<span class="thread-parent-name" id="thread-parent-name">Thread starter</span>
</div>
<div class="thread-parent-preview" id="thread-parent-preview"></div>
</div>
<div class="thread-messages" id="thread-messages"></div>
<div class="thread-reply-bar" id="thread-reply-bar" style="display:none">
<span id="thread-reply-preview-text"></span>
<button id="thread-reply-close-btn" class="icon-btn small" aria-label="Cancel thread reply">&times;</button>
</div>
<div class="thread-input-area">
<textarea id="thread-input" class="thread-input" placeholder="Reply in thread..." rows="1"></textarea>
<button id="thread-send-btn" class="thread-send-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/>
</svg>
</button>
</div>
</div>
<!-- First-Time Setup Wizard (Admin only) -->
<div class="modal-overlay" id="setup-wizard-modal" style="display:none">
<div class="modal modal-wizard">
@ -1106,6 +1137,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>

View file

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

View file

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

View file

@ -1,9 +1,15 @@
// ── 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);
const _appUrl = _pendingInvite ? `/app?invite=${encodeURIComponent(_pendingInvite)}` : '/app';
// If already logged in, redirect to app
if (localStorage.getItem('haven_token')) {
window.location.href = '/app';
window.location.href = _appUrl;
return;
}
@ -191,7 +197,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 +298,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 +331,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'));
}
@ -388,6 +394,8 @@
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');
@ -398,7 +406,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;
@ -439,44 +511,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
@ -498,6 +565,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('/')) {
@ -509,7 +595,7 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: ssoProfileData.username,
username: registerUsername,
password,
eulaVersion: '2.0',
ageVerified: true,
@ -527,7 +613,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'));
}
@ -565,7 +651,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}

View file

@ -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');

View file

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

View file

@ -531,33 +531,85 @@ _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><button data-action="quote" title="${t('msg_toolbar.quote')}">💬</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 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>` }
];
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', '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) {
@ -579,6 +631,7 @@ _createMessageEl(msg, prevMsg) {
<div class="message-content">${pinnedTag}${archivedTag}${this._formatContent(msg.content)}${editedHtml}</div>
${pollHtml}
${reactionsHtml}
${threadHtml}
</div>
${e2eTag}
${toolbarHtml}
@ -665,6 +718,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>
@ -1070,6 +1124,7 @@ _enterMoveSelectionMode() {
_exitMoveSelectionMode() {
this._moveSelectionActive = false;
this._moveSelectedIds.clear();
this._lastMoveSelectedEl = null;
document.body.classList.remove('move-selection-mode');
const toolbar = document.getElementById('move-msg-toolbar');
if (toolbar) toolbar.style.display = 'none';

View file

@ -492,6 +492,7 @@ 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
@ -502,6 +503,7 @@ async _initE2E() {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch { /* silent — best-effort background sync */ }
}
}, 5 * 60 * 1000);
@ -517,6 +519,7 @@ async _initE2E() {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch { /* silent */ }
}
});

View file

@ -296,6 +296,7 @@ _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);
@ -728,6 +729,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) {
@ -960,7 +1000,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;

View file

@ -1475,6 +1475,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();
@ -1495,7 +1664,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
@ -1504,7 +1673,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
@ -1524,6 +1716,8 @@ _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') {
@ -1547,6 +1741,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);
@ -1559,6 +1754,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');

View file

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

View file

@ -25,7 +25,20 @@ class ServerManager {
_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 with mismatched casing
const seen = new Set();
const deduped = [];
for (const s of raw) {
try { s.url = new URL(s.url).origin; } catch {}
if (seen.has(s.url)) continue;
seen.add(s.url);
deduped.push(s);
}
if (deduped.length !== raw.length) {
localStorage.setItem('haven_servers', JSON.stringify(deduped));
}
return deduped;
} catch { return []; }
}
@ -35,7 +48,7 @@ class ServerManager {
add(name, url, icon = null) {
url = this._normalizeUrl(url);
if (this.servers.find(s => s.url === url)) return false;
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();
@ -66,6 +79,20 @@ class ServerManager {
this.markRemoved(url);
}
/** 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();
}
getAll() {
return this.servers.map(s => ({
...s,
@ -191,8 +218,8 @@ 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)

View file

@ -1036,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>
@ -1065,52 +1069,105 @@ router.get('/SSO', (req, res) => {
<script>
const authCode = '${safeAuthCode}';
const origin = '${safeOrigin}';
let approvedProfile = null;
(async function() {
function showNotLoggedIn() {
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') {
showNotLoggedIn('SSO check timed out. Try refreshing this page or logging in again.');
}
}, 10000);
let token;
try {
setDebug('Reading local login token...');
token = localStorage.getItem('haven_token');
} catch {
// localStorage blocked (third-party cookies, popup restrictions, etc.)
showNotLoggedIn();
showNotLoggedIn('Browser storage is blocked in this tab, so Haven cannot read your login token.');
clearTimeout(bootTimeout);
return;
}
if (!token) {
showNotLoggedIn();
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(), 7000);
const verifyRes = await fetch('/api/auth/validate', {
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + token },
signal: ctrl.signal
});
if (!verifyRes.ok) { showNotLoggedIn(); return; }
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 = userData.displayName || userData.username || '—';
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = userData.avatar ? 'Will be shared' : 'None set';
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
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(); return; }
if (!user) {
showNotLoggedIn('Could not validate token and no cached local user profile was found.');
clearTimeout(bootTimeout);
return;
}
document.getElementById('sso-username').textContent = user.displayName || user.username || '—';
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';
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
showNotLoggedIn();
showNotLoggedIn('Failed to read cached local profile for SSO consent.');
clearTimeout(bootTimeout);
return;
}
}
@ -1126,15 +1183,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';
@ -1174,6 +1244,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' });
@ -1183,11 +1260,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);
@ -1201,7 +1275,8 @@ router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
}
res.json({
username: user.display_name || user.username,
username: user.username,
displayName: user.display_name || user.username,
profilePicture: avatarUrl
});
});

View file

@ -807,6 +807,14 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_bot_commands_webhook ON bot_commands(webhook_id);
`);
// ── Migration: chat threads (thread_id on messages) ─────
try {
db.prepare("SELECT thread_id FROM messages LIMIT 0").get();
} catch {
db.exec("ALTER TABLE messages ADD COLUMN thread_id INTEGER DEFAULT NULL REFERENCES messages(id) ON DELETE CASCADE");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id) WHERE thread_id IS NOT NULL");
return db;
}

View file

@ -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);
}
@ -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);
@ -373,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;
@ -418,7 +455,8 @@ module.exports = function register(socket, ctx) {
reply_to: replyTo,
replyContext: null,
reactions: [],
edited_at: null
edited_at: null,
thread: null
};
if (replyTo) {
@ -954,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] = []; });
@ -1119,4 +1158,181 @@ module.exports = function register(socket, ctx) {
console.error('Mark read channel error:', err);
}
});
// ═══════════════════════════════════════════════════════
// THREADS
// ═══════════════════════════════════════════════════════
// ── Get thread messages ─────────────────────────────────
socket.on('get-thread-messages', (data) => {
if (!data || typeof data !== 'object') return;
const parentId = isInt(data.parentId) ? data.parentId : null;
if (!parentId) return;
const code = socket.currentChannel;
if (!code) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
if (!channel) return;
// Verify parent message belongs to this channel and fetch OP metadata
const parent = db.prepare(`
SELECT m.id,
m.content,
m.created_at,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username,
COALESCE(m.webhook_avatar, u.avatar) as avatar,
COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m
LEFT JOIN users u ON m.user_id = u.id
WHERE m.id = ? AND m.channel_id = ?
`).get(parentId, channel.id);
if (!parent) return;
const messages = db.prepare(`
SELECT m.id, m.content, m.created_at, m.reply_to, m.edited_at, m.is_webhook, m.webhook_username, m.webhook_avatar, m.imported_from, m.is_archived,
COALESCE(m.webhook_username, u.display_name, u.username, '[Deleted User]') as username, u.id as user_id, u.avatar, COALESCE(u.avatar_shape, 'circle') as avatar_shape
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ?
ORDER BY m.created_at ASC
`).all(parentId);
// Enrich with reactions and reply context
const msgIds = messages.map(m => m.id);
const replyIds = [...new Set(messages.filter(m => m.reply_to).map(m => m.reply_to))];
const replyMap = new Map();
if (replyIds.length > 0) {
const ph = replyIds.map(() => '?').join(',');
db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id IN (${ph})
`).all(...replyIds).forEach(r => replyMap.set(r.id, r));
}
const reactionMap = new Map();
if (msgIds.length > 0) {
const ph = msgIds.map(() => '?').join(',');
db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, COALESCE(u.display_name, u.username) as username
FROM reactions r JOIN users u ON r.user_id = u.id WHERE r.message_id IN (${ph}) ORDER BY r.id
`).all(...msgIds).forEach(r => {
if (!reactionMap.has(r.message_id)) reactionMap.set(r.message_id, []);
reactionMap.get(r.message_id).push({ emoji: r.emoji, user_id: r.user_id, username: r.username });
});
}
const enriched = messages.map(m => {
const obj = { ...m };
if (obj.created_at && !obj.created_at.endsWith('Z')) obj.created_at = utcStamp(obj.created_at);
if (obj.edited_at && !obj.edited_at.endsWith('Z')) obj.edited_at = utcStamp(obj.edited_at);
obj.replyContext = m.reply_to ? (replyMap.get(m.reply_to) || null) : null;
obj.reactions = reactionMap.get(m.id) || [];
return obj;
});
socket.emit('thread-messages', {
parentId,
parentContent: parent.content,
parentUsername: parent.username || '[Deleted User]',
parentAvatar: parent.avatar || null,
parentAvatarShape: parent.avatar_shape || 'circle',
parentCreatedAt: utcStamp(parent.created_at),
messages: enriched
});
});
// ── Send message to thread ──────────────────────────────
socket.on('send-thread-message', (data, callback) => {
if (!data || typeof data !== 'object') return;
const parentId = isInt(data.parentId) ? data.parentId : null;
let content = typeof data.content === 'string' ? data.content.trim() : '';
if (!parentId || !content) return;
if (floodCheck('message')) return;
const code = socket.currentChannel;
if (!code) return;
const channel = db.prepare('SELECT id, is_dm FROM channels WHERE code = ?').get(code);
if (!channel) return;
// Verify parent message belongs to this channel and is not itself a thread message
const parent = db.prepare('SELECT id, thread_id, channel_id FROM messages WHERE id = ? AND channel_id = ?').get(parentId, channel.id);
if (!parent || parent.thread_id) return; // Can't create sub-threads
const safeContent = sanitizeText(content);
if (!safeContent) return;
let replyTo = isInt(data.replyTo) ? data.replyTo : null;
if (replyTo) {
const replyMsg = db.prepare('SELECT thread_id FROM messages WHERE id = ?').get(replyTo);
if (!replyMsg || replyMsg.thread_id !== parentId) replyTo = null;
}
try {
const result = db.prepare(
'INSERT INTO messages (channel_id, user_id, content, thread_id, reply_to) VALUES (?, ?, ?, ?, ?)'
).run(channel.id, socket.user.id, safeContent, parentId, replyTo);
const message = {
id: result.lastInsertRowid,
content: safeContent,
created_at: new Date().toISOString(),
username: socket.user.displayName,
user_id: socket.user.id,
avatar: socket.user.avatar || null,
avatar_shape: socket.user.avatar_shape || 'circle',
reply_to: replyTo,
replyContext: null,
reactions: [],
edited_at: null,
thread_id: parentId
};
if (replyTo) {
message.replyContext = db.prepare(`
SELECT m.id, m.content, m.user_id, COALESCE(u.display_name, u.username, '[Deleted User]') as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(replyTo) || null;
}
// Emit to everyone in the channel who has the thread open
io.to(`channel:${code}`).emit('new-thread-message', {
channelCode: code,
parentId,
message
});
// Update thread preview on the parent message for all users
const threadCount = db.prepare('SELECT COUNT(*) as count FROM messages WHERE thread_id = ?').get(parentId);
const lastMsg = db.prepare(`
SELECT m.id, m.content, m.created_at, COALESCE(u.display_name, u.username) as username
FROM messages m LEFT JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 1
`).get(parentId);
// Get up to 5 unique participants
const participants = db.prepare(`
SELECT DISTINCT COALESCE(u.display_name, u.username) as username, u.avatar
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 5
`).all(parentId);
io.to(`channel:${code}`).emit('thread-updated', {
channelCode: code,
parentId,
thread: {
count: threadCount.count,
lastReplyAt: lastMsg ? lastMsg.created_at : null,
participants: participants.map(p => ({ username: p.username, avatar: p.avatar }))
}
});
if (typeof callback === 'function') callback({ success: true });
} catch (err) {
console.error('send-thread-message error:', err.message);
if (typeof callback === 'function') callback({ error: 'Failed to send thread message' });
}
});
};

View file

@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v3.4.0</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.4.0 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v3.4.0</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">