chat: peer-switch strip, back button moved to flyout header

Switching conversations used to require bouncing back to the list view
and picking from there. Now the thread view has a horizontal peer
strip at the top: one click hops to another conversation, active tab
is highlighted and auto-scrolls into view, unread peers show a dot.
Scrollbar is hidden — vertical wheel delta is redirected to horizontal
scroll so the user can slide the strip with the mouse wheel when it
overflows.

In-thread header is gone; the back button moved next to "chat" in the
flyout header and only shows while a thread is open. Peer name stays
visible via the active tab.

Bumps to 1.9.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-15 19:30:38 +02:00
1 parent bfd5420981
commit 81d9ce0e87
5 files changed
+111 -14

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.2'); define('APP_VERSION', '1.9.3');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.2", "version": "1.9.3",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+47 -4
View File
@@ -142,15 +142,58 @@
/* ── Thread view ── */ /* ── Thread view ── */
#chat-thread-header { /* Horizontal peer strip above the thread header. Lets the user hop
between conversations without bouncing back to the list. Scrollbar
hidden — wheel-over scrolls horizontally (see chat.js). */
.chat-thread-peers {
display: flex; display: flex;
align-items: center; gap: 5px;
gap: 8px; padding: 6px 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--t-border); border-bottom: 1px solid var(--t-border);
background: var(--t-bg); background: var(--t-bg);
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.chat-thread-peers::-webkit-scrollbar { display: none; }
.chat-thread-peers:empty { display: none; }
.chat-peer-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-button, 3px);
background: var(--t-surface);
color: var(--t-muted);
font-family: var(--t-font-pixel);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s, background 0.12s;
position: relative;
}
.chat-peer-tab:hover {
border-color: var(--t-accent);
color: var(--t-accent);
}
.chat-peer-tab.active {
border-color: var(--t-accent);
background: var(--t-accent);
color: var(--t-bg);
}
.chat-peer-tab .chat-peer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--t-accent);
flex-shrink: 0; flex-shrink: 0;
} }
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
.chat-thread-messages { .chat-thread-messages {
flex: 1; flex: 1;
+4 -6
View File
@@ -3,7 +3,9 @@
<div id="chat-flyout" class="hidden"> <div id="chat-flyout" class="hidden">
<div id="chat-flyout-header"> <div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i> <i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span> <span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
<span class="flex-1"></span>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button> <button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button> <button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button> <button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
@@ -21,11 +23,7 @@
<!-- Conversation thread view --> <!-- Conversation thread view -->
<div id="chat-thread-view" class="chat-view hidden"> <div id="chat-thread-view" class="chat-view hidden">
<div id="chat-thread-header"> <div id="chat-thread-peers" class="chat-thread-peers"></div>
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<i class="ph ph-user-circle text-t-accent text-sm"></i>
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
</div>
<div id="chat-thread-messages" class="chat-thread-messages"></div> <div id="chat-thread-messages" class="chat-thread-messages"></div>
<form id="chat-thread-form"> <form id="chat-thread-form">
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000"> <input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
+58 -2
View File
@@ -142,6 +142,7 @@ function showListView() {
$('chat-thread-view').classList.add('hidden'); $('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.add('hidden'); $('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden');
currentPeer = null; currentPeer = null;
} }
@@ -150,14 +151,17 @@ function showThreadView(peer) {
$('chat-thread-view').classList.remove('hidden'); $('chat-thread-view').classList.remove('hidden');
$('chat-pick-view').classList.add('hidden'); $('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-thread-peer').textContent = peer; $('chat-header-back')?.classList.remove('hidden');
currentPeer = peer; currentPeer = peer;
// Re-render tabs so the active highlight follows the thread switch.
renderPeerTabs();
} }
function showPickView() { function showPickView() {
$('chat-list-view').classList.add('hidden'); $('chat-list-view').classList.add('hidden');
$('chat-thread-view').classList.add('hidden'); $('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.remove('hidden'); $('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
} }
// ── Conversation list ───────────────────────────────────────────── // ── Conversation list ─────────────────────────────────────────────
@@ -174,6 +178,7 @@ function renderConversations() {
const list = $('chat-list'); const list = $('chat-list');
const empty = $('chat-empty'); const empty = $('chat-empty');
if (!list || !empty) return; if (!list || !empty) return;
renderPeerTabs();
if (!conversations.length) { if (!conversations.length) {
list.innerHTML = ''; list.innerHTML = '';
empty.classList.remove('hidden'); empty.classList.remove('hidden');
@@ -205,6 +210,56 @@ function renderConversations() {
}); });
} }
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
// Horizontal row above the thread so the user can hop between
// conversations without going back to the list. Hidden scrollbar;
// vertical wheel delta is redirected to horizontal scroll.
function renderPeerTabs() {
const strip = $('chat-thread-peers');
if (!strip) return;
// Fold the current peer in even if no messages exist yet (fresh
// pick-view selection), so switching back to it via tab is always
// possible.
const peers = conversations.slice();
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
peers.unshift({ peer: currentPeer, unread: 0 });
}
if (!peers.length) { strip.innerHTML = ''; return; }
strip.innerHTML = peers.map(c => {
const active = c.peer === currentPeer ? ' active' : '';
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
? '<span class="chat-peer-dot"></span>' : '';
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
}).join('');
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const peer = tab.dataset.peer;
if (peer && peer !== currentPeer) openConversation(peer);
});
});
// Keep the active tab in view after a switch so the user can see
// what's selected when the list overflows.
const active = strip.querySelector('.chat-peer-tab.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// Redirect vertical wheel scrolls to horizontal scroll on the peer
// strip. Attached once on init — preventDefault only when we actually
// consume the delta, so a trackpad user's native horizontal gesture
// still works.
function wirePeerStripWheel() {
const strip = $('chat-thread-peers');
if (!strip || strip.dataset.wheelWired) return;
strip.dataset.wheelWired = '1';
strip.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (strip.scrollWidth <= strip.clientWidth) return;
e.preventDefault();
strip.scrollLeft += e.deltaY;
}, { passive: false });
}
// ── Thread ──────────────────────────────────────────────────────── // ── Thread ────────────────────────────────────────────────────────
async function openConversation(peer) { async function openConversation(peer) {
@@ -380,9 +435,10 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations)); $('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView); $('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView); $('chat-empty-start')?.addEventListener('click', openPickView);
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); }); $('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
$('chat-pick-back').addEventListener('click', showListView); $('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage); $('chat-thread-form').addEventListener('submit', sendMessage);
wirePeerStripWheel();
// Esc closes flyout when focus is inside it // Esc closes flyout when focus is inside it
$('chat-flyout').addEventListener('keydown', e => { $('chat-flyout').addEventListener('keydown', e => {