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:
1 parent
bfd5420981
commit
81d9ce0e87
5 files changed
+111
-14
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.2');
|
||||
define('APP_VERSION', '1.9.3');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.3",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -142,15 +142,58 @@
|
||||
|
||||
/* ── 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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
gap: 5px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
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;
|
||||
}
|
||||
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
|
||||
|
||||
.chat-thread-messages {
|
||||
flex: 1;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<div id="chat-flyout" class="hidden">
|
||||
<div id="chat-flyout-header">
|
||||
<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-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>
|
||||
@@ -21,11 +23,7 @@
|
||||
|
||||
<!-- Conversation thread view -->
|
||||
<div id="chat-thread-view" class="chat-view hidden">
|
||||
<div id="chat-thread-header">
|
||||
<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-peers" class="chat-thread-peers"></div>
|
||||
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
||||
<form id="chat-thread-form">
|
||||
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
|
||||
|
||||
@@ -142,6 +142,7 @@ function showListView() {
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-header-back')?.classList.add('hidden');
|
||||
currentPeer = null;
|
||||
}
|
||||
|
||||
@@ -150,14 +151,17 @@ function showThreadView(peer) {
|
||||
$('chat-thread-view').classList.remove('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-thread-peer').textContent = peer;
|
||||
$('chat-header-back')?.classList.remove('hidden');
|
||||
currentPeer = peer;
|
||||
// Re-render tabs so the active highlight follows the thread switch.
|
||||
renderPeerTabs();
|
||||
}
|
||||
|
||||
function showPickView() {
|
||||
$('chat-list-view').classList.add('hidden');
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.remove('hidden');
|
||||
$('chat-header-back')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Conversation list ─────────────────────────────────────────────
|
||||
@@ -174,6 +178,7 @@ function renderConversations() {
|
||||
const list = $('chat-list');
|
||||
const empty = $('chat-empty');
|
||||
if (!list || !empty) return;
|
||||
renderPeerTabs();
|
||||
if (!conversations.length) {
|
||||
list.innerHTML = '';
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
async function openConversation(peer) {
|
||||
@@ -380,9 +435,10 @@ function init() {
|
||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||
$('chat-new-btn').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-thread-form').addEventListener('submit', sendMessage);
|
||||
wirePeerStripWheel();
|
||||
|
||||
// Esc closes flyout when focus is inside it
|
||||
$('chat-flyout').addEventListener('keydown', e => {
|
||||
|
||||
Reference in new issue
Block a user