From 1e768e799b1fdb7465f93bf136faafb17ff64e45 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 25 Mar 2026 07:56:32 +0100 Subject: [PATCH] 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 --- app/static/css/style.css | 177 ++++++++++++++++++++++++++++++++ app/static/js/app.js | 107 +++++++++++++++++++- app/static/js/dm.js | 176 +++++++++++++++++++++++++++++++- app/templates/dm.html | 212 ++++++++++++++++++++++----------------- app/templates/index.html | 161 +++++++++++++++-------------- 5 files changed, 660 insertions(+), 173 deletions(-) diff --git a/app/static/css/style.css b/app/static/css/style.css index 107b9f0..4a5b2d5 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; diff --git a/app/static/js/app.js b/app/static/js/app.js index 5e6e099..0a2d373 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 */ diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 117d9ba..1d34ce4 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -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() {

Select a conversation

- Choose from the dropdown above or start a new chat from channel messages + Choose from the list or start a new chat from channel messages
`; } + // Update desktop header and sidebar + updateDmDesktopHeader(); + updateDmSidebarActive(); + updateCharCounter(); } diff --git a/app/templates/dm.html b/app/templates/dm.html index 78c962e..cafec77 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -189,110 +189,134 @@
- -
-
-
- -
- - -
- - - - + +
+ +
+
+ +
+
+
-
- -
-
- -
-
- - - - + + +
+
+
+ +
+
+ +
-
-
- -
- -

Select a conversation

- Choose from the dropdown above or start a new chat from channel messages + +
+ +
+
+ + + +
+
+
+ +
+ +

Select a conversation

+ Choose from the list or start a new chat from channel messages +
+
+
+ +
- - -
-
- - -
-
-
-
-
- - - + +
+ +
+
+ + + +
+ +
- - +
+ 0 / 150 +
+ +
+ +
+
+ + Connecting... + + Updated: Never
-
- 0 / 150 -
- -
-
- - -
-
-
- - Connecting... - - Updated: Never
diff --git a/app/templates/index.html b/app/templates/index.html index c1168f1..0c48912 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -73,92 +73,99 @@ {% block content %}
- -
-
- -
-
-
- - - -
- - - - -
+ +
+ +
+
+ Channels
-
-
- -
-
- Loading... -
-

Loading messages...

-
-
+
+
- -
-
- - -
-
-
-
-
- - - +
- - - - +
+
+ +
+
+ Loading... +
+

Loading messages...

+
-
- 0 / 135 + + +
+ +
+ +
+
+ + + +
+ + + + +
+
+ 0 / 135 +
+ +
+ +
+
+ + Connecting... + + Updated: Never
- -
-
- - -
-
-
- - Connecting... - - Updated: Never