feat(ui): add channel/contact sidebar for wide screens (desktop/tablet)

On screens >= 992px (lg breakpoint), show a persistent sidebar panel:
- Group chat: channel list with unread badges, active highlight, muted state
- DM: conversation/contact list with search, unread dots, type badges
- Desktop contact header with info button replaces mobile selector
- Mobile/narrow screens unchanged (dropdown/top selector still used)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-25 07:56:32 +01:00
parent 7b2f721d1d
commit 1e768e799b
5 changed files with 660 additions and 173 deletions

View File

@@ -27,6 +27,183 @@ main {
min-height: 0; /* Important for flex children */
}
/* =============================================================================
Channel Sidebar (Group Chat) - visible on lg+ screens
============================================================================= */
.channel-sidebar {
display: none;
width: 250px;
flex-shrink: 0;
border-right: 1px solid #dee2e6;
background: #f8f9fa;
flex-direction: column;
}
.channel-sidebar-header {
padding: 0.6rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
border-bottom: 1px solid #dee2e6;
color: #495057;
background: #f0f0f0;
}
.channel-sidebar-list {
overflow-y: auto;
flex: 1;
}
.channel-sidebar-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
transition: background-color 0.15s;
color: #212529;
}
.channel-sidebar-item:hover {
background-color: #e9ecef;
}
.channel-sidebar-item.active {
background-color: #e7f1ff;
border-left: 3px solid #0d6efd;
padding-left: calc(0.75rem - 3px);
font-weight: 500;
color: #0d6efd;
}
.channel-sidebar-item.muted {
opacity: 0.5;
}
.channel-sidebar-item .channel-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-sidebar-item .sidebar-unread-badge {
background-color: #0d6efd;
color: white;
border-radius: 10px;
padding: 1px 6px;
font-size: 0.7rem;
font-weight: bold;
min-width: 18px;
text-align: center;
flex-shrink: 0;
}
/* Show sidebar and hide dropdown on wide screens */
@media (min-width: 992px) {
.channel-sidebar {
display: flex;
}
#channelSelector {
display: none !important;
}
}
/* =============================================================================
DM Sidebar (Direct Messages) - visible on lg+ screens
============================================================================= */
.dm-sidebar {
display: none;
width: 280px;
flex-shrink: 0;
border-right: 1px solid #dee2e6;
background: #f8f9fa;
flex-direction: column;
}
.dm-sidebar-header {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
background: #f0f0f0;
}
.dm-sidebar-list {
overflow-y: auto;
flex: 1;
}
.dm-sidebar-separator {
padding: 0.25rem 0.75rem;
font-size: 0.7rem;
color: #6c757d;
background: #f0f0f0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.dm-sidebar-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
transition: background-color 0.15s;
}
.dm-sidebar-item:hover {
background-color: #e9ecef;
}
.dm-sidebar-item.active {
background-color: #e7f5ee;
border-left: 3px solid #198754;
padding-left: calc(0.75rem - 3px);
font-weight: 500;
}
.dm-sidebar-item .contact-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-sidebar-item .sidebar-unread-dot {
width: 8px;
height: 8px;
background-color: #0d6efd;
border-radius: 50%;
flex-shrink: 0;
}
.dm-sidebar-item .badge {
font-size: 0.65rem;
flex-shrink: 0;
}
/* Show DM sidebar and hide mobile selector on wide screens */
@media (min-width: 992px) {
.dm-sidebar {
display: flex;
}
.dm-mobile-selector {
display: none !important;
}
.dm-desktop-header {
display: block !important;
}
}
.dm-desktop-header {
display: none;
}
/* Messages Container */
.messages-container {
background-color: #ffffff;

View File

@@ -689,11 +689,12 @@ function setupEventListeners() {
loadDeviceInfo();
});
// Channel selector
// Channel selector (dropdown, visible on mobile)
document.getElementById('channelSelector').addEventListener('change', function(e) {
currentChannelIdx = parseInt(e.target.value);
localStorage.setItem('mc_active_channel', currentChannelIdx);
loadMessages();
updateChannelSidebarActive();
// Show notification only if we have a valid selection
const selectedOption = e.target.options[e.target.selectedIndex];
@@ -2888,6 +2889,9 @@ function updateUnreadBadges() {
// Update app icon badge
updateAppBadge();
// Update channel sidebar badges (lg+ screens)
updateChannelSidebarBadges();
}
/**
@@ -3111,6 +3115,9 @@ function populateChannelSelector(channels) {
}
console.log(`[populateChannelSelector] Loaded ${channels.length} channels, active: ${currentChannelIdx}`);
// Also populate sidebar (lg+ screens)
populateChannelSidebar();
}
/**
@@ -3179,6 +3186,104 @@ function displayChannelsList(channels) {
});
}
/**
* Populate channel sidebar (visible on lg+ screens)
*/
function populateChannelSidebar() {
const list = document.getElementById('channelSidebarList');
if (!list) return;
list.innerHTML = '';
const channels = availableChannels.length > 0
? availableChannels
: [{index: 0, name: 'Public', key: ''}];
channels.forEach(channel => {
if (!channel || typeof channel.index === 'undefined' || !channel.name) return;
const item = document.createElement('div');
item.className = 'channel-sidebar-item';
item.dataset.channelIdx = channel.index;
if (channel.index === currentChannelIdx) {
item.classList.add('active');
}
if (mutedChannels.has(channel.index)) {
item.classList.add('muted');
}
const nameSpan = document.createElement('span');
nameSpan.className = 'channel-name';
nameSpan.textContent = channel.name;
item.appendChild(nameSpan);
// Unread badge
const unread = unreadCounts[channel.index] || 0;
if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) {
const badge = document.createElement('span');
badge.className = 'sidebar-unread-badge';
badge.textContent = unread;
item.appendChild(badge);
}
item.addEventListener('click', () => {
currentChannelIdx = channel.index;
localStorage.setItem('mc_active_channel', currentChannelIdx);
loadMessages();
updateChannelSidebarActive();
// Also sync dropdown for consistency
const selector = document.getElementById('channelSelector');
if (selector) selector.value = currentChannelIdx;
});
list.appendChild(item);
});
}
/**
* Update active state on channel sidebar items
*/
function updateChannelSidebarActive() {
const list = document.getElementById('channelSidebarList');
if (!list) return;
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
const idx = parseInt(item.dataset.channelIdx);
item.classList.toggle('active', idx === currentChannelIdx);
});
}
/**
* Update unread badges on channel sidebar
*/
function updateChannelSidebarBadges() {
const list = document.getElementById('channelSidebarList');
if (!list) return;
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
const idx = parseInt(item.dataset.channelIdx);
const unread = unreadCounts[idx] || 0;
const isMuted = mutedChannels.has(idx);
// Update muted state
item.classList.toggle('muted', isMuted);
// Update or remove badge
let badge = item.querySelector('.sidebar-unread-badge');
if (unread > 0 && idx !== currentChannelIdx && !isMuted) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'sidebar-unread-badge';
item.appendChild(badge);
}
badge.textContent = unread;
} else if (badge) {
badge.remove();
}
});
}
/**
* Toggle mute state for a channel
*/

View File

@@ -324,6 +324,25 @@ function setupEventListeners() {
scrollToBottomBtn.classList.remove('visible');
});
}
// DM Sidebar search input (lg+ screens)
const sidebarSearch = document.getElementById('dmSidebarSearch');
if (sidebarSearch) {
sidebarSearch.addEventListener('input', () => {
populateDmSidebar(sidebarSearch.value);
});
}
// Desktop info button (lg+ screens)
const desktopInfoBtn = document.getElementById('dmDesktopInfoBtn');
if (desktopInfoBtn) {
desktopInfoBtn.addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
populateContactInfoModal();
loadPathSection();
modal.show();
});
}
}
/**
@@ -423,12 +442,16 @@ function populateConversationSelector() {
window._dmDropdownItems = { conversations, contacts };
renderDropdownItems('');
// Also populate DM sidebar (lg+ screens)
populateDmSidebar('');
// Update search input if conversation is selected — re-resolve name in case contacts loaded
if (currentConversationId) {
const bestName = resolveConversationName(currentConversationId);
if (!isPubkey(bestName)) currentRecipient = bestName;
const input = document.getElementById('dmContactSearchInput');
if (input) input.value = displayName(currentRecipient);
updateDmDesktopHeader();
}
}
@@ -519,6 +542,149 @@ function createDropdownItem(name, conversationId, isUnread, contact) {
return el;
}
/**
* Populate the DM sidebar (visible on lg+ screens).
* Mirrors the dropdown data structure but renders as a persistent list.
*/
function populateDmSidebar(query) {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.innerHTML = '';
const q = (query || '').toLowerCase().trim();
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
const filteredConvs = q
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
: conversations;
const filteredContacts = q
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
: contacts;
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Recent conversations';
list.appendChild(sep);
filteredConvs.forEach(item => {
list.appendChild(createSidebarItem(
item.name, item.conversationId, item.isUnread, item.contact));
});
}
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Contacts';
list.appendChild(sep);
filteredContacts.forEach(contact => {
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
const convId = `pk_${prefix}`;
list.appendChild(createSidebarItem(
contact.name, convId, false, contact));
});
}
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-sidebar-separator text-center';
empty.textContent = q ? 'No matches' : 'No contacts available';
list.appendChild(empty);
}
}
/**
* Create a single sidebar item element for the DM sidebar.
*/
function createSidebarItem(name, conversationId, isUnread, contact) {
const el = document.createElement('div');
el.className = 'dm-sidebar-item';
el.dataset.conversationId = conversationId;
if (conversationId === currentConversationId) {
el.classList.add('active');
}
if (isUnread) {
const dot = document.createElement('span');
dot.className = 'sidebar-unread-dot';
el.appendChild(dot);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'contact-name';
nameSpan.textContent = displayName(name);
el.appendChild(nameSpan);
if (contact && contact.type_label) {
const badge = document.createElement('span');
badge.className = 'badge';
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
badge.textContent = contact.type_label;
el.appendChild(badge);
}
el.addEventListener('click', () => selectConversationFromSidebar(conversationId, name));
return el;
}
/**
* Handle selection from the DM sidebar.
*/
async function selectConversationFromSidebar(conversationId, name) {
await selectConversation(conversationId);
if (name && !isPubkey(name)) currentRecipient = name;
updateDmSidebarActive();
// Move focus to message input
const msgInput = document.getElementById('dmMessageInput');
if (msgInput && !msgInput.disabled) msgInput.focus();
}
/**
* Update active state on DM sidebar items.
*/
function updateDmSidebarActive() {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.querySelectorAll('.dm-sidebar-item').forEach(item => {
const convId = item.dataset.conversationId;
// Flexible matching: handle prefix upgrades
let isActive = convId === currentConversationId;
if (!isActive && currentConversationId && convId) {
// Match if one is a prefix of the other (pk_ based)
if (convId.startsWith('pk_') && currentConversationId.startsWith('pk_')) {
const a = convId.substring(3);
const b = currentConversationId.substring(3);
isActive = a.startsWith(b) || b.startsWith(a);
}
}
item.classList.toggle('active', isActive);
});
}
/**
* Update the desktop contact header (visible on lg+ screens).
*/
function updateDmDesktopHeader() {
const nameEl = document.getElementById('dmDesktopContactName');
const infoBtn = document.getElementById('dmDesktopInfoBtn');
if (!nameEl) return;
if (currentRecipient) {
nameEl.textContent = displayName(currentRecipient);
if (infoBtn) infoBtn.disabled = false;
} else {
nameEl.textContent = '';
if (infoBtn) infoBtn.disabled = true;
}
}
/**
* Handle selection from the searchable dropdown.
*/
@@ -582,6 +748,10 @@ async function selectConversation(conversationId) {
sendBtn.disabled = false;
}
// Update desktop header and sidebar (lg+ screens)
updateDmDesktopHeader();
updateDmSidebarActive();
// Load messages
await loadMessages();
}
@@ -623,11 +793,15 @@ function clearConversation() {
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
</div>
`;
}
// Update desktop header and sidebar
updateDmDesktopHeader();
updateDmSidebarActive();
updateCharCounter();
}

View File

@@ -189,9 +189,26 @@
<!-- Main Content -->
<main>
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
<!-- Conversation Selector Bar -->
<div class="row border-bottom bg-light">
<div class="col-12 p-2">
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- DM Sidebar (visible on lg+ screens) -->
<div id="dmSidebar" class="dm-sidebar">
<div class="dm-sidebar-header">
<input type="text"
id="dmSidebarSearch"
class="form-control form-control-sm"
placeholder="Search contacts..."
autocomplete="off">
</div>
<div class="dm-sidebar-list" id="dmSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
<div class="dm-mobile-selector border-bottom bg-light">
<div class="p-2">
<div class="d-flex align-items-center gap-2">
<!-- Searchable contact selector -->
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
@@ -221,9 +238,21 @@
</div>
</div>
</div>
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
<div class="dm-desktop-header border-bottom bg-light d-none">
<div class="p-2 d-flex align-items-center gap-2">
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
<button type="button"
class="btn btn-outline-secondary btn-sm flex-shrink-0"
id="dmDesktopInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
@@ -243,7 +272,7 @@
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
</div>
</div>
</div>
@@ -252,11 +281,8 @@
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<div class="border-top bg-light">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
@@ -283,11 +309,8 @@
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
@@ -297,6 +320,7 @@
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->
<div class="fab-container" id="dmFabContainer">

View File

@@ -73,9 +73,21 @@
{% block content %}
<div class="container-fluid d-flex flex-column" style="height: 100%;">
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- Channel Sidebar (visible on lg+ screens) -->
<div id="channelSidebar" class="channel-sidebar">
<div class="channel-sidebar-header">
<i class="bi bi-broadcast-pin"></i> Channels
</div>
<div class="channel-sidebar-list" id="channelSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
@@ -114,11 +126,8 @@
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<div class="border-top bg-light">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
@@ -149,11 +158,8 @@
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
@@ -162,6 +168,7 @@
</div>
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->