🖼️ Image Display
diff --git a/public/css/style.css b/public/css/style.css
index 55227b6..4838d07 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -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 ──────────────────────────── */
diff --git a/public/js/app.js b/public/js/app.js
index 76dba35..b40a64f 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -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': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','�','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
- 'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','�','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
+ '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','�':'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','�':'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();
diff --git a/public/js/auth.js b/public/js/auth.js
index 5f1492f..35c9a9b 100644
--- a/public/js/auth.js
+++ b/public/js/auth.js
@@ -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 = `
`;
+ } 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 = `
`;
- } 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'));
}
diff --git a/public/js/modules/app-channels.js b/public/js/modules/app-channels.js
index 484289a..c0721af 100644
--- a/public/js/modules/app-channels.js
+++ b/public/js/modules/app-channels.js
@@ -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');
diff --git a/public/js/modules/app-media.js b/public/js/modules/app-media.js
index 45f697d..ebd5a5a 100644
--- a/public/js/modules/app-media.js
+++ b/public/js/modules/app-media.js
@@ -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 = `
+
${actionLabels[key] || key}
+
+ ▲
+ ▼
+
+ `;
+ 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') {
diff --git a/public/js/modules/app-messages.js b/public/js/modules/app-messages.js
index b186d80..886439d 100644
--- a/public/js/modules/app-messages.js
+++ b/public/js/modules/app-messages.js
@@ -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 ? `
${t('app.messages.edited')} ` : '';
const pinnedTag = msg.pinned ? `
📌 ` : '';
const archivedTag = msg.is_archived ? `
🛡️ ` : '';
const e2eTag = msg._e2e ? `
🔒 ` : '';
- // Build toolbar with context-aware buttons
- let toolbarBtns = `
😀 ↩️ 💬 `;
+ const iconPair = (emoji, monoSvg) => `
${emoji} ${monoSvg} `;
+ const iReact = iconPair('😀', '
');
+ const iReply = iconPair('↩️', '
');
+ const iQuote = iconPair('💬', '
');
+ const iThread = iconPair('🧵', '
');
+ const iPin = iconPair('📌', '
');
+ const iArchive = iconPair('🛡️', '
');
+ const iEdit = iconPair('✏️', '
');
+ const iDelete = iconPair('🗑️', '
');
+ const iMore = iconPair('⋯', '
');
+
+ const toolbarActions = [
+ { key: 'react', html: `
${iReact} ` },
+ { key: 'reply', html: `
${iReply} ` },
+ { key: 'quote', html: `
${iQuote} ` },
+ { key: 'thread', html: `
${iThread} ` }
+ ];
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
- ? `
📌 `
- : `
📌 `;
+ toolbarActions.push({
+ key: 'pin',
+ html: msg.pinned
+ ? `
${iPin} `
+ : `
${iPin} `
+ });
}
if (canArchive) {
- toolbarBtns += msg.is_archived
- ? `
🛡️ `
- : `
🛡️ `;
+ toolbarActions.push({
+ key: 'archive',
+ html: msg.is_archived
+ ? `
${iArchive} `
+ : `
${iArchive} `
+ });
}
if (msg.user_id === this.user.id) {
- toolbarBtns += `
✏️ `;
+ toolbarActions.push({ key: 'edit', html: `
${iEdit} ` });
}
if (canDelete) {
- toolbarBtns += `
🗑️ `;
+ toolbarActions.push({ key: 'delete', html: `
${iDelete} ` });
}
- const toolbarHtml = `
${toolbarBtns}
`;
+
+ 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
+ ? `
`
+ : '';
+ const toolbarHtml = `
`;
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
if (isCompact) {
@@ -579,6 +631,7 @@ _createMessageEl(msg, prevMsg) {
${pinnedTag}${archivedTag}${this._formatContent(msg.content)}${editedHtml}
${pollHtml}
${reactionsHtml}
+ ${threadHtml}
${e2eTag}
${toolbarHtml}
@@ -665,6 +718,7 @@ _createMessageEl(msg, prevMsg) {