mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
feat: add threads, toolbar customization, and SSO/auth flow improvements
This commit is contained in:
parent
47454daead
commit
83fbfb5fd4
20 changed files with 2245 additions and 150 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1415,12 +1415,12 @@
|
|||
</div>
|
||||
|
||||
<div class="download-card fade-in">
|
||||
<h2>⬡ Haven Server — v3.4.0</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ View on GitHub</a>
|
||||
|
|
@ -1437,7 +1437,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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — Quote/edit UX, bot API upgrades, SSO quality fixes</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">×</button>
|
||||
</div>
|
||||
<div class="thread-parent-meta" id="thread-parent-meta">
|
||||
<div class="thread-parent-avatar-wrap" id="thread-parent-avatar-wrap"></div>
|
||||
<span class="thread-parent-name" id="thread-parent-name">Thread starter</span>
|
||||
</div>
|
||||
<div class="thread-parent-preview" id="thread-parent-preview"></div>
|
||||
</div>
|
||||
<div class="thread-messages" id="thread-messages"></div>
|
||||
<div class="thread-reply-bar" id="thread-reply-bar" style="display:none">
|
||||
<span id="thread-reply-preview-text"></span>
|
||||
<button id="thread-reply-close-btn" class="icon-btn small" aria-label="Cancel thread reply">×</button>
|
||||
</div>
|
||||
<div class="thread-input-area">
|
||||
<textarea id="thread-input" class="thread-input" placeholder="Reply in thread..." rows="1"></textarea>
|
||||
<button id="thread-send-btn" class="thread-send-btn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- First-Time Setup Wizard (Admin only) -->
|
||||
<div class="modal-overlay" id="setup-wizard-modal" style="display:none">
|
||||
<div class="modal modal-wizard">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class HavenApp {
|
|||
this.serverManager = new ServerManager();
|
||||
this.notifications = new NotificationManager();
|
||||
this.replyingTo = null; // message object being replied to
|
||||
this._threadReplyingTo = null; // thread message being replied to
|
||||
this._activeThreadParent = null; // currently open thread parent message ID
|
||||
this._lastMoveSelectedEl = null; // last clicked message in move-selection mode
|
||||
this._imageQueue = []; // queued images awaiting send
|
||||
this.channelMembers = []; // for @mention autocomplete
|
||||
this.mentionQuery = ''; // current partial @mention being typed
|
||||
|
|
@ -95,11 +98,11 @@ class HavenApp {
|
|||
'Monkeys': ['🙈','🙉','🙊','🐵','🐒','🦍','🦧'],
|
||||
'Animals': ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐔','🐧','🐦','🦆','🦅','🦉','🐺','🐴','🦄','🐝','🦋','🐌','🐞','🐢','🐍','🐙','🐬','🐳','🦈','🐊','🦖','🦕','🐋','🦭','🦦','🦫','🦥','🐿️','🦔','🦇','🐓','🦃','🦚','🦜','🦢','🦩','🐕','🐈','🐈⬛'],
|
||||
'Faces': ['👀','👁️','👁️🗨️','👅','👄','🫦','💋','🧠','🦷','🦴','👃','👂','🦻','🦶','🦵','💀','☠️','👽','🤖','🎃','😺','😸','😹','😻','😼','😽','🙀','😿','😾'],
|
||||
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','<EFBFBD>','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
|
||||
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','<EFBFBD>','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
|
||||
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','🍻','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
|
||||
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','🎶','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
|
||||
'Travel': ['🚗','🚕','🚀','✈️','🚁','🛸','🚢','🏠','🏢','🏰','🗼','🗽','⛩️','🌋','🏔️','🌊','🌅','🌄','🌉','🎡','🎢','🗺️','🧭','🏖️','🏕️','🌍','🌎','🌏','🛳️','⛵','🚂','🚇','🏎️','🏍️','🛵','🛶'],
|
||||
'Objects': ['⌚','📱','💻','⌨️','🖥️','💾','📷','🔭','🔬','💡','🔦','📚','📝','✏️','📎','📌','🔑','🔒','🔓','🛡️','⚔️','🔧','💰','💎','📦','🎁','✉️','🔔','🪙','💸','🏷️','🔨','🪛','🧲','🧪','🧫','💊','🩺','🩹','🧬','💬','💭','🗨️','🗯️','📣','📢','🔊','🔇','📰','🗞️','📋','📁','📂','🗂️','📅','📆','🗓️','🖊️','🖋️','✒️','📏','📐','🗑️','👑','💍','👒','🎩','🧢','👓','🕶️','🧳','🌂','☂️'],
|
||||
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','➕','➖','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️🌈','🏴☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','ℹ️','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','©️','®️','™️']
|
||||
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','!','?',',','.','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','➕','➖','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️🌈','🏴☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','ℹ️','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','©️','®️','™️']
|
||||
};
|
||||
|
||||
// Flat list for quick access (used by search)
|
||||
|
|
@ -110,16 +113,19 @@ class HavenApp {
|
|||
'😀':'grinning happy','😁':'beaming grin','😂':'joy tears laughing lol','🤣':'rofl rolling laughing','😃':'smiley happy','😄':'smile happy','😅':'sweat nervous','😆':'laughing satisfied','😉':'wink','😊':'blush happy shy','😋':'yummy delicious','😎':'cool sunglasses','😍':'heart eyes love','🥰':'loving smiling hearts','😘':'kiss blowing','🙂':'slight smile','🤗':'hug hugging open hands','🤩':'starstruck star eyes','🤔':'thinking hmm','😐':'neutral expressionless','🙄':'eye roll','😏':'smirk','😣':'persevere','😥':'sad relieved disappointed','😮':'open mouth wow surprised','😯':'hushed surprised','😴':'sleeping zzz','😛':'tongue playful','😜':'wink tongue crazy','😝':'squinting tongue','😒':'unamused','😔':'pensive sad','🙃':'upside down','😲':'astonished shocked','😤':'triumph huff angry steam','😭':'crying sob loudly','😢':'cry sad tear','😱':'scream fear horrified','🥺':'pleading puppy eyes please','😠':'angry mad','😡':'rage pouting furious','🤬':'cursing swearing angry','😈':'devil smiling imp','💀':'skull dead','💩':'poop poo','🤡':'clown','👻':'ghost boo','😺':'cat smile','😸':'cat grin','🫠':'melting face','🫣':'peeking eye','🫢':'hand over mouth','🫥':'dotted line face','🫤':'diagonal mouth','🥹':'holding back tears','🥲':'smile tear','😶🌫️':'face in clouds','🤭':'giggling hand over mouth','🫡':'salute','🤫':'shush quiet secret','🤥':'lying pinocchio','😬':'grimace awkward','🫨':'shaking face','😵':'dizzy','😵💫':'face spiral eyes','🥴':'woozy drunk','😮💨':'exhale sigh relief','🥱':'yawn tired boring','😇':'angel innocent halo','🤠':'cowboy yeehaw','🤑':'money face rich','🤓':'nerd glasses','👿':'devil angry imp','🫶':'heart hands','🤧':'sneeze sick','😷':'mask sick','🤒':'thermometer sick','🤕':'bandage hurt','💅':'nail polish sassy',
|
||||
'👋':'wave hello hi bye','🤚':'raised back hand','✋':'hand stop high five','🖖':'vulcan spock','👌':'ok okay perfect','🤌':'pinched italian','✌️':'peace victory','🤞':'crossed fingers luck','🤟':'love you hand','🤘':'rock on metal','🤙':'call me shaka hang loose','👈':'point left','👉':'point right','👆':'point up','👇':'point down','☝️':'index up','👍':'thumbs up like good yes','👎':'thumbs down dislike bad no','✊':'fist bump','👊':'punch fist bump','🤛':'left fist bump','🤜':'right fist bump','👏':'clap applause','🙌':'raising hands celebrate','🤝':'handshake deal','🙏':'pray please thank you namaste','💪':'strong muscle flex bicep','💃':'dancer dancing woman','🕺':'man dancing','🤳':'selfie','🖕':'middle finger','🫰':'pinch','🫳':'palm down','🫴':'palm up','👐':'open hands','🤲':'palms up','🫱':'right hand','🫲':'left hand','🤷':'shrug idk','🤦':'facepalm','🙇':'bow','💁':'info','🙆':'ok gesture','🙅':'no gesture','🙋':'raising hand hi','🧏':'deaf',
|
||||
'🐶':'dog puppy','🐱':'cat kitty','🐭':'mouse','🐹':'hamster','🐰':'rabbit bunny','🦊':'fox','🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow','🐷':'pig','🐸':'frog','🐔':'chicken','🐧':'penguin','🐦':'bird','🦆':'duck','🦅':'eagle','🦉':'owl','🐺':'wolf','🐴':'horse','🦄':'unicorn','🐝':'bee','🦋':'butterfly','🐌':'snail','🐞':'ladybug','🐢':'turtle','🐍':'snake','🐙':'octopus','🐬':'dolphin','🐳':'whale','🦈':'shark','🐊':'crocodile alligator','🦖':'trex dinosaur','🦕':'dinosaur brontosaurus',
|
||||
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','<EFBFBD>':'clinking beers cheers','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
|
||||
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','<EFBFBD>':'music notes','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
|
||||
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','🍻':'clinking beers cheers toast','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
|
||||
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','🎶':'music notes melody song','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
|
||||
'🚗':'car automobile','🚀':'rocket space launch','✈️':'airplane plane travel','🏠':'house home','🏰':'castle','🌊':'wave ocean water','🌅':'sunrise','🌍':'globe earth world','🌈':'rainbow',
|
||||
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
|
||||
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','!':'exclamation punctuation bang','?':'question punctuation mark',',':'comma punctuation','.':'period punctuation dot','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
|
||||
'🙈':'see no evil monkey','🙉':'hear no evil monkey','🙊':'speak no evil monkey',
|
||||
'👀':'eyes looking','👅':'tongue','👄':'mouth lips','💋':'kiss lips','🧠':'brain smart','🦷':'tooth','🦴':'bone','💀':'skull dead','☠️':'skull crossbones','👽':'alien','🤖':'robot','🎃':'jack o lantern pumpkin halloween',
|
||||
'📱':'phone mobile','💻':'laptop computer','📷':'camera photo','📚':'books reading','📝':'memo note write','🔑':'key','🔒':'lock locked','💎':'gem diamond jewel','🎁':'gift present','🔔':'bell notification','💰':'money bag rich','🔨':'hammer tool','💬':'speech bubble chat','💭':'thought bubble thinking','🗨️':'speech balloon','🗯️':'anger bubble','📣':'megaphone announcement','📢':'loudspeaker','👑':'crown king queen royal','💍':'ring diamond wedding','🕶️':'sunglasses cool'
|
||||
};
|
||||
|
||||
if (!this.token || !this.user) {
|
||||
// Preserve invite param so it survives the redirect to the auth page
|
||||
const _inv = new URLSearchParams(window.location.search).get('invite');
|
||||
if (_inv) sessionStorage.setItem('haven_pending_invite', _inv);
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
|
@ -181,6 +187,7 @@ class HavenApp {
|
|||
this._setupEmojiSizePicker();
|
||||
this._setupImageModePicker();
|
||||
this._setupRoleDisplayPicker();
|
||||
this._setupToolbarIconPicker();
|
||||
this._setupLightbox();
|
||||
this._setupOnlineOverlay();
|
||||
this._setupModalExpand();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -209,9 +209,18 @@ _formatContent(str) {
|
|||
// Render `inline code`
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
|
||||
// Render > blockquotes (lines starting with >)
|
||||
html = html.replace(/(?:^|\n)>\s?(.+)/g, (_, text) => {
|
||||
return `\n<blockquote class="chat-blockquote">${text}</blockquote>`;
|
||||
// Render grouped > blockquotes and preserve attribution lines inside the quote.
|
||||
const blockquotes = [];
|
||||
html = html.replace(/(^|\n)((?:>[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
|
||||
const lines = block.trim().split('\n').map(line => line.replace(/^>\s?/, ''));
|
||||
let authorHtml = '';
|
||||
if (lines[0] && /^@[^\s].+ wrote:$/.test(lines[0])) {
|
||||
authorHtml = `<div class="chat-blockquote-author">${lines.shift()}</div>`;
|
||||
}
|
||||
const textHtml = lines.join('<br>');
|
||||
const idx = blockquotes.length;
|
||||
blockquotes.push(`${pre}<blockquote class="chat-blockquote">${authorHtml}<div class="chat-blockquote-body">${textHtml}</div></blockquote>`);
|
||||
return `\x00BLOCKQUOTE_${idx}\x00`;
|
||||
});
|
||||
|
||||
// ── Headings: # H1, ## H2, ### H3 at start of line ──
|
||||
|
|
@ -243,6 +252,10 @@ _formatContent(str) {
|
|||
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
blockquotes.forEach((block, idx) => {
|
||||
html = html.replace(`\x00BLOCKQUOTE_${idx}\x00`, block);
|
||||
});
|
||||
|
||||
// ── Restore fenced code blocks ──
|
||||
codeBlocks.forEach((block, idx) => {
|
||||
const escaped = this._escapeHtml(block.code).replace(/\n$/, '');
|
||||
|
|
@ -919,6 +932,7 @@ _renderReactions(msgId, reactions) {
|
|||
const badges = Object.values(grouped).map(g => {
|
||||
const isOwn = g.users.some(u => u.id === this.user.id);
|
||||
const names = g.users.map(u => u.username).join(', ');
|
||||
const usersJson = this._escapeHtml(JSON.stringify(g.users.map(u => u.username)));
|
||||
// Check if it's a custom emoji
|
||||
const customMatch = g.emoji.match(/^:([a-zA-Z0-9_-]+):$/);
|
||||
let emojiDisplay = g.emoji;
|
||||
|
|
@ -926,7 +940,7 @@ _renderReactions(msgId, reactions) {
|
|||
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
|
||||
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
|
||||
}
|
||||
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
|
||||
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" data-users="${usersJson}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="reactions-row">${badges}</div>`;
|
||||
|
|
@ -946,8 +960,8 @@ _updateMessageReactions(messageId, reactions) {
|
|||
const html = this._renderReactions(messageId, reactions);
|
||||
if (!html) { if (wasAtBottom) this._scrollToBottom(true); return; }
|
||||
|
||||
// Find where to insert — after .message-content
|
||||
const content = msgEl.querySelector('.message-content');
|
||||
// Find where to insert — after main or thread message content
|
||||
const content = msgEl.querySelector('.message-content, .thread-msg-content');
|
||||
if (content) {
|
||||
content.insertAdjacentHTML('afterend', html);
|
||||
}
|
||||
|
|
@ -955,6 +969,49 @@ _updateMessageReactions(messageId, reactions) {
|
|||
if (wasAtBottom) this._scrollToBottom(true);
|
||||
},
|
||||
|
||||
// ── Reaction popout (who reacted) ─────────────────────
|
||||
|
||||
_showReactionPopout(badge) {
|
||||
this._hideReactionPopout();
|
||||
let users;
|
||||
try { users = JSON.parse(badge.dataset.users || '[]'); } catch { return; }
|
||||
if (!users.length) return;
|
||||
|
||||
const emoji = badge.dataset.emoji;
|
||||
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
|
||||
let emojiDisplay = emoji;
|
||||
if (customMatch && this.customEmojis) {
|
||||
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
|
||||
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
|
||||
}
|
||||
|
||||
const popout = document.createElement('div');
|
||||
popout.id = 'reaction-popout';
|
||||
popout.className = 'reaction-popout';
|
||||
popout.innerHTML = `
|
||||
<div class="reaction-popout-header">${emojiDisplay} <span class="reaction-popout-count">${users.length}</span></div>
|
||||
<div class="reaction-popout-list">
|
||||
${users.map(u => `<div class="reaction-popout-user">${this._escapeHtml(u)}</div>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(popout);
|
||||
|
||||
// Position above the badge
|
||||
const rect = badge.getBoundingClientRect();
|
||||
popout.style.left = rect.left + 'px';
|
||||
popout.style.top = (rect.top - popout.offsetHeight - 6) + 'px';
|
||||
// Clamp to viewport
|
||||
const pr = popout.getBoundingClientRect();
|
||||
if (pr.right > window.innerWidth) popout.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
||||
if (pr.left < 0) popout.style.left = '8px';
|
||||
if (pr.top < 0) popout.style.top = (rect.bottom + 6) + 'px';
|
||||
},
|
||||
|
||||
_hideReactionPopout() {
|
||||
const existing = document.getElementById('reaction-popout');
|
||||
if (existing) existing.remove();
|
||||
},
|
||||
|
||||
_getQuickEmojis() {
|
||||
const saved = localStorage.getItem('haven_quick_emojis');
|
||||
if (saved) {
|
||||
|
|
@ -1179,17 +1236,10 @@ _showReactionPicker(msgEl, msgId) {
|
|||
// Flip picker below the message if it would be clipped above
|
||||
requestAnimationFrame(() => {
|
||||
const pickerRect = picker.getBoundingClientRect();
|
||||
if (pickerRect.top < 0) {
|
||||
const container = msgEl.closest('#thread-messages, #messages');
|
||||
const containerTop = container ? container.getBoundingClientRect().top : 0;
|
||||
if (pickerRect.top < containerTop + 4) {
|
||||
picker.classList.add('flip-below');
|
||||
} else {
|
||||
// Also check against the messages container top (channel header/topic)
|
||||
const container = document.getElementById('messages');
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
if (pickerRect.top < containerRect.top) {
|
||||
picker.classList.add('flip-below');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1311,11 +1361,308 @@ _showFullReactionPicker(msgEl, msgId, quickPicker) {
|
|||
searchTimer = setTimeout(() => renderAll(searchInput.value.trim()), 150);
|
||||
});
|
||||
|
||||
// Position the panel near the quick picker
|
||||
msgEl.appendChild(panel);
|
||||
// Position the panel relative to quick picker so it never overlaps it
|
||||
quickPicker.appendChild(panel);
|
||||
if (quickPicker.classList.contains('flip-below')) {
|
||||
panel.classList.add('flip-below');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const container = msgEl.closest('#thread-messages, #messages');
|
||||
const containerTop = container ? container.getBoundingClientRect().top : 0;
|
||||
if (panelRect.top < containerTop + 4) {
|
||||
panel.classList.add('flip-below');
|
||||
}
|
||||
});
|
||||
searchInput.focus();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// THREADS
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
_renderThreadPreview(parentId, thread) {
|
||||
if (!thread || !thread.count) return '';
|
||||
const participantAvatars = (thread.participants || []).map(p => {
|
||||
if (p.avatar) {
|
||||
return `<img class="thread-participant-avatar" src="${this._escapeHtml(p.avatar)}" alt="${this._escapeHtml(p.username)}" title="${this._escapeHtml(p.username)}">`;
|
||||
}
|
||||
const color = this._getUserColor(p.username);
|
||||
const initial = p.username.charAt(0).toUpperCase();
|
||||
return `<div class="thread-participant-avatar thread-participant-initial" style="background:${color}" title="${this._escapeHtml(p.username)}">${initial}</div>`;
|
||||
}).join('');
|
||||
|
||||
const timeAgo = this._relativeTime(thread.lastReplyAt);
|
||||
return `
|
||||
<button class="thread-preview" data-thread-parent="${parentId}">
|
||||
${participantAvatars}
|
||||
<span class="thread-preview-count">${thread.count} ${thread.count === 1 ? 'Reply' : 'Replies'}</span>
|
||||
<span class="thread-preview-time">${timeAgo}</span>
|
||||
<span class="thread-preview-arrow">›</span>
|
||||
</button>
|
||||
`;
|
||||
},
|
||||
|
||||
_relativeTime(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const diff = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
},
|
||||
|
||||
_setThreadParentHeader(meta = {}) {
|
||||
const wrap = document.getElementById('thread-parent-avatar-wrap');
|
||||
const nameEl = document.getElementById('thread-parent-name');
|
||||
if (!wrap || !nameEl) return;
|
||||
|
||||
const username = (meta.username || '').trim() || 'Thread starter';
|
||||
const shape = (meta.avatarShape || 'circle') === 'square' ? 'square' : 'circle';
|
||||
const shapeClass = shape === 'square' ? ' thread-parent-avatar-square' : '';
|
||||
|
||||
if (meta.avatar) {
|
||||
wrap.innerHTML = `<img class="thread-parent-avatar${shapeClass}" src="${this._escapeHtml(meta.avatar)}" alt="${this._escapeHtml(username)}">`;
|
||||
} else {
|
||||
const initial = username.charAt(0).toUpperCase() || '?';
|
||||
const color = this._getUserColor(username);
|
||||
wrap.innerHTML = `<div class="thread-parent-avatar-initial${shapeClass}" style="background:${color}">${this._escapeHtml(initial)}</div>`;
|
||||
}
|
||||
|
||||
nameEl.textContent = username;
|
||||
nameEl.title = username;
|
||||
},
|
||||
|
||||
_setThreadReply(msgEl, msgId) {
|
||||
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
|
||||
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
|
||||
const preview = rawContent.length > 70 ? rawContent.substring(0, 70) + '…' : rawContent;
|
||||
this._threadReplyingTo = { id: msgId, username: author, content: rawContent };
|
||||
|
||||
const bar = document.getElementById('thread-reply-bar');
|
||||
const text = document.getElementById('thread-reply-preview-text');
|
||||
if (!bar || !text) return;
|
||||
bar.style.display = 'flex';
|
||||
text.innerHTML = `Replying to <strong>${this._escapeHtml(author)}</strong>: ${this._escapeHtml(preview)}`;
|
||||
|
||||
const input = document.getElementById('thread-input');
|
||||
if (input) input.focus();
|
||||
},
|
||||
|
||||
_clearThreadReply() {
|
||||
this._threadReplyingTo = null;
|
||||
const bar = document.getElementById('thread-reply-bar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
},
|
||||
|
||||
_quoteThreadMessage(msgEl) {
|
||||
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
|
||||
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
|
||||
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
|
||||
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
|
||||
|
||||
const input = document.getElementById('thread-input');
|
||||
if (!input) return;
|
||||
if (input.value) {
|
||||
input.value += '\n' + quoteText;
|
||||
} else {
|
||||
input.value = quoteText;
|
||||
}
|
||||
input.focus();
|
||||
input.dispatchEvent(new Event('input'));
|
||||
},
|
||||
|
||||
_openThread(parentId) {
|
||||
this._activeThreadParent = parentId;
|
||||
const panel = document.getElementById('thread-panel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'flex';
|
||||
panel.dataset.parentId = parentId;
|
||||
this._setThreadPiPEnabled(localStorage.getItem('haven_thread_panel_pip') === '1');
|
||||
|
||||
// Request thread messages from server
|
||||
this.socket.emit('get-thread-messages', { parentId });
|
||||
|
||||
// Update header
|
||||
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
|
||||
const author = msgEl?.querySelector('.message-author')?.textContent || 'Thread starter';
|
||||
document.getElementById('thread-panel-title').textContent = 'Thread';
|
||||
const parentPreview = msgEl?.querySelector('.message-content')?.textContent || '';
|
||||
document.getElementById('thread-parent-preview').textContent = parentPreview.length > 120 ? parentPreview.substring(0, 120) + '…' : parentPreview;
|
||||
|
||||
const avatarImg = msgEl?.querySelector('.message-avatar-img');
|
||||
let avatar = null;
|
||||
if (avatarImg && avatarImg.getAttribute('src')) avatar = avatarImg.getAttribute('src');
|
||||
const avatarShape = (avatarImg && avatarImg.classList.contains('avatar-square')) ? 'square' : 'circle';
|
||||
this._setThreadParentHeader({ username: author, avatar, avatarShape });
|
||||
|
||||
// Focus input
|
||||
const input = document.getElementById('thread-input');
|
||||
if (input) input.focus();
|
||||
},
|
||||
|
||||
_setThreadPiPEnabled(enabled) {
|
||||
const panel = document.getElementById('thread-panel');
|
||||
const pipBtn = document.getElementById('thread-panel-pip');
|
||||
if (!panel || !pipBtn) return;
|
||||
|
||||
const isOn = !!enabled;
|
||||
panel.classList.toggle('pip', isOn);
|
||||
pipBtn.textContent = isOn ? '▣' : '⧉';
|
||||
pipBtn.title = isOn ? 'Dock thread panel' : 'Pop out thread (PiP)';
|
||||
pipBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
|
||||
localStorage.setItem('haven_thread_panel_pip', isOn ? '1' : '0');
|
||||
|
||||
if (isOn) {
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem('haven_thread_panel_pip_rect') || 'null'); } catch {}
|
||||
|
||||
const minW = 320;
|
||||
const maxW = Math.min(760, window.innerWidth - 28);
|
||||
const minH = 240;
|
||||
const footerOffset = (() => {
|
||||
const raw = getComputedStyle(document.body).getPropertyValue('--thread-footer-offset');
|
||||
const v = parseInt(raw, 10);
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
})();
|
||||
const maxH = Math.max(minH, window.innerHeight - footerOffset - 28);
|
||||
|
||||
const width = Math.max(minW, Math.min(maxW, (saved && saved.width) || panel.offsetWidth || 420));
|
||||
const height = Math.max(minH, Math.min(maxH, (saved && saved.height) || panel.offsetHeight || 460));
|
||||
const defaultLeft = Math.max(0, window.innerWidth - width - 14);
|
||||
const defaultTop = Math.max(0, window.innerHeight - footerOffset - height - 14);
|
||||
const left = Math.max(0, Math.min(window.innerWidth - width, (saved && Number.isFinite(saved.left)) ? saved.left : defaultLeft));
|
||||
const top = Math.max(0, Math.min(window.innerHeight - footerOffset - height, (saved && Number.isFinite(saved.top)) ? saved.top : defaultTop));
|
||||
|
||||
panel.style.width = `${Math.round(width)}px`;
|
||||
panel.style.height = `${Math.round(height)}px`;
|
||||
panel.style.left = `${Math.round(left)}px`;
|
||||
panel.style.top = `${Math.round(top)}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
} else {
|
||||
panel.style.height = '';
|
||||
panel.style.left = '';
|
||||
panel.style.top = '';
|
||||
panel.style.right = '';
|
||||
panel.style.bottom = '';
|
||||
}
|
||||
},
|
||||
|
||||
_toggleThreadPiP() {
|
||||
const panel = document.getElementById('thread-panel');
|
||||
if (!panel) return;
|
||||
this._setThreadPiPEnabled(!panel.classList.contains('pip'));
|
||||
},
|
||||
|
||||
_closeThread() {
|
||||
this._activeThreadParent = null;
|
||||
this._clearThreadReply();
|
||||
const panel = document.getElementById('thread-panel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
panel.dataset.parentId = '';
|
||||
}
|
||||
},
|
||||
|
||||
_sendThreadMessage() {
|
||||
const input = document.getElementById('thread-input');
|
||||
if (!input) return;
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
const parentId = this._activeThreadParent;
|
||||
if (!parentId) return;
|
||||
const replyTo = this._threadReplyingTo ? this._threadReplyingTo.id : null;
|
||||
|
||||
this.socket.emit('send-thread-message', { parentId, content, replyTo }, (resp) => {
|
||||
if (resp && resp.error) {
|
||||
this._showToast(resp.error, 'error');
|
||||
return;
|
||||
}
|
||||
this._clearThreadReply();
|
||||
});
|
||||
input.value = '';
|
||||
},
|
||||
|
||||
_appendThreadMessage(msg) {
|
||||
const container = document.getElementById('thread-messages');
|
||||
if (!container) return;
|
||||
|
||||
const color = this._getUserColor(msg.username);
|
||||
const initial = msg.username.charAt(0).toUpperCase();
|
||||
let avatarHtml;
|
||||
if (msg.avatar) {
|
||||
avatarHtml = `<img class="thread-msg-avatar" src="${this._escapeHtml(msg.avatar)}" alt="${initial}">`;
|
||||
} else {
|
||||
avatarHtml = `<div class="thread-msg-avatar thread-msg-avatar-initial" style="background:${color}">${initial}</div>`;
|
||||
}
|
||||
|
||||
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
|
||||
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
|
||||
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
|
||||
const canEdit = msg.user_id === this.user.id;
|
||||
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
|
||||
const iReact = iconPair('😀', '<svg class="thread-action-react-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
|
||||
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
|
||||
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
|
||||
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
|
||||
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
|
||||
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
|
||||
const threadCoreToolbarBtns = `<button data-thread-action="react" title="React" aria-label="React">${iReact}</button><button data-thread-action="reply" title="Reply">${iReply}</button><button data-thread-action="quote" title="Quote">${iQuote}</button>`;
|
||||
let threadOverflowToolbarBtns = '';
|
||||
if (canEdit) threadOverflowToolbarBtns += `<button data-thread-action="edit" title="Edit">${iEdit}</button>`;
|
||||
if (canDelete) threadOverflowToolbarBtns += `<button data-thread-action="delete" title="Delete">${iDelete}</button>`;
|
||||
const threadOverflowHtml = threadOverflowToolbarBtns
|
||||
? `<div class="thread-msg-more"><button class="thread-msg-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="thread-msg-overflow">${threadOverflowToolbarBtns}</div></div>`
|
||||
: '';
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'thread-message';
|
||||
el.dataset.msgId = msg.id;
|
||||
el.dataset.rawContent = msg.content;
|
||||
el.innerHTML = `
|
||||
<div class="thread-msg-row">
|
||||
${avatarHtml}
|
||||
<div class="thread-msg-body">
|
||||
<div class="thread-msg-header">
|
||||
<span class="thread-msg-author" style="color:${color}">${this._escapeHtml(msg.username)}</span>
|
||||
<span class="thread-msg-time">${this._formatTime(msg.created_at)}</span>
|
||||
<span class="thread-msg-header-spacer"></span>
|
||||
<div class="thread-msg-toolbar">
|
||||
<div class="msg-toolbar-group">${threadCoreToolbarBtns}</div>
|
||||
${threadOverflowHtml}
|
||||
</div>
|
||||
</div>
|
||||
${replyHtml}
|
||||
<div class="thread-msg-content">${this._formatContent(msg.content)}</div>
|
||||
${reactionsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(el);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
|
||||
_updateThreadPreview(parentId, thread) {
|
||||
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
|
||||
if (!msgEl) return;
|
||||
const oldPreview = msgEl.querySelector('.thread-preview');
|
||||
const newHtml = this._renderThreadPreview(parentId, thread);
|
||||
if (oldPreview) {
|
||||
oldPreview.outerHTML = newHtml;
|
||||
} else if (newHtml) {
|
||||
// Insert after reactions row, or after message-content
|
||||
const reactions = msgEl.querySelector('.reactions-row');
|
||||
const content = msgEl.querySelector('.message-content');
|
||||
const insertAfter = reactions || content;
|
||||
if (insertAfter) insertAfter.insertAdjacentHTML('afterend', newHtml);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// REPLY
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
113
src/auth.js
113
src/auth.js
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1415,12 +1415,12 @@
|
|||
</div>
|
||||
|
||||
<div class="download-card fade-in">
|
||||
<h2>⬡ Haven Server — v3.4.0</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ View on GitHub</a>
|
||||
|
|
@ -1437,7 +1437,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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — Quote/edit UX, bot API upgrades, SSO quality fixes</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
Loading…
Reference in a new issue