diff --git a/app/static/css/style.css b/app/static/css/style.css index dbd20de..4fb93d0 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -870,6 +870,48 @@ main { transition: transform 0.2s ease; } +/* FAB toggle button (smaller, semi-transparent) */ +.fab-toggle { + width: 32px; + height: 32px; + background: rgba(108, 117, 125, 0.6); + color: white; + font-size: 0.85rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.fab-toggle:hover { + background: rgba(108, 117, 125, 0.9); +} + +/* Collapsed state - hide all FABs except toggle */ +.fab-container.collapsed .fab:not(.fab-toggle) { + opacity: 0; + pointer-events: none; + transform: scale(0); + height: 0; + width: 0; + margin: 0; + overflow: hidden; +} + +.fab-container.collapsed { + gap: 0; +} + +/* Smooth transitions for collapse */ +.fab-container .fab:not(.fab-toggle) { + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, height 0.2s ease, width 0.2s ease; +} + +.fab-toggle i { + transition: transform 0.2s ease; +} + +.fab-container.collapsed .fab-toggle i { + transform: rotate(180deg); +} + /* Mobile optimization */ @media (max-width: 768px) { .fab-container { @@ -883,6 +925,12 @@ main { height: 48px; font-size: 1.25rem; } + + .fab-toggle { + width: 28px; + height: 28px; + font-size: 0.75rem; + } } /* ============================================================================= @@ -1220,6 +1268,15 @@ main { visibility: visible; } +/* Push messages container down when filter bar is visible */ +.messages-container { + transition: padding-top 0.3s ease; +} + +.filter-bar.visible ~ .messages-container { + padding-top: calc(1rem + 52px) !important; /* 52px ≈ filter bar height (0.75rem*2 padding + 36px input + border) */ +} + /* Filter bar inner layout */ .filter-bar-inner { display: flex; @@ -1228,7 +1285,6 @@ main { } .filter-bar-input { - flex: 1; border-radius: 0.375rem; border: 1px solid #ced4da; padding: 0.5rem 0.75rem; @@ -1307,6 +1363,24 @@ main { display: block; } +/* Filter input wrapper for mentions popup positioning */ +.filter-input-wrapper { + flex: 1; + position: relative; +} + +.filter-input-wrapper .filter-bar-input { + width: 100%; +} + +/* Filter mentions popup - appears below input (not above like message input) */ +.filter-mentions-popup { + bottom: auto !important; + top: 100% !important; + margin-top: 0.25rem; + margin-bottom: 0; +} + /* Mobile responsive filter bar */ @media (max-width: 576px) { .filter-bar { diff --git a/app/static/js/app.js b/app/static/js/app.js index e9eed45..29a2add 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -324,6 +324,9 @@ document.addEventListener('DOMContentLoaded', async function() { // Initialize filter functionality initializeFilter(); + // Initialize FAB toggle + initializeFabToggle(); + // Setup auto-refresh immediately after messages are displayed // Don't wait for geo cache - it's not needed for auto-refresh setupAutoRefresh(); @@ -2808,6 +2811,22 @@ async function loadContactsForMentions() { } } +// ============================================================================= +// FAB Toggle (Collapse/Expand) +// ============================================================================= + +function initializeFabToggle() { + const toggle = document.getElementById('fabToggle'); + const container = document.getElementById('fabContainer'); + if (!toggle || !container) return; + + toggle.addEventListener('click', () => { + container.classList.toggle('collapsed'); + const isCollapsed = container.classList.contains('collapsed'); + toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons'; + }); +} + // ============================================================================= // Chat Filter Functionality // ============================================================================= @@ -2834,9 +2853,14 @@ function initializeFilter() { openFilterBar(); }); - // Filter as user types (debounced) + // Filter as user types (debounced) - also check for @mentions let filterTimeout = null; filterInput.addEventListener('input', () => { + // Check for @mention trigger + if (handleFilterMentionInput(filterInput)) { + return; // Don't apply filter while picking a mention + } + clearTimeout(filterTimeout); filterTimeout = setTimeout(() => { applyFilter(filterInput.value); @@ -2847,6 +2871,7 @@ function initializeFilter() { filterClearBtn.addEventListener('click', () => { filterInput.value = ''; applyFilter(''); + hideFilterMentionsPopup(); filterInput.focus(); }); @@ -2855,11 +2880,30 @@ function initializeFilter() { closeFilterBar(); }); - // Keyboard shortcuts + // Keyboard shortcuts (with mentions navigation support) filterInput.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeFilterBar(); + // If filter mentions popup is active, handle navigation + if (filterMentionActive) { + if (handleFilterMentionKeydown(e)) return; } + if (e.key === 'Escape') { + if (filterMentionActive) { + hideFilterMentionsPopup(); + e.preventDefault(); + } else { + closeFilterBar(); + } + } + }); + + // Close filter mentions on blur + filterInput.addEventListener('blur', () => { + setTimeout(hideFilterMentionsPopup, 200); + }); + + // Preload contacts when filter bar is focused + filterInput.addEventListener('focus', () => { + loadContactsForMentions(); }); // Global keyboard shortcut: Ctrl+F to open filter @@ -2896,6 +2940,7 @@ function closeFilterBar() { filterBar.classList.remove('visible'); filterActive = false; + hideFilterMentionsPopup(); // Reset filter filterInput.value = ''; @@ -3026,6 +3071,164 @@ function getMessageId(messageEl) { return 'msg_' + children.indexOf(messageEl); } +// ============================================================================= +// Filter Mentions Autocomplete +// ============================================================================= + +let filterMentionActive = false; +let filterMentionStartPos = -1; +let filterMentionSelectedIndex = 0; + +/** + * Handle input in filter bar to detect @mention trigger + * @returns {boolean} true if in mention mode (caller should skip filter apply) + */ +function handleFilterMentionInput(input) { + const cursorPos = input.selectionStart; + const text = input.value; + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtPos = textBeforeCursor.lastIndexOf('@'); + + if (lastAtPos >= 0) { + const textAfterAt = textBeforeCursor.substring(lastAtPos + 1); + // No whitespace after @ means we're typing a mention + if (!/[\s\n]/.test(textAfterAt)) { + filterMentionStartPos = lastAtPos; + filterMentionActive = true; + showFilterMentionsPopup(textAfterAt); + return true; + } + } + + if (filterMentionActive) { + hideFilterMentionsPopup(); + } + return false; +} + +/** + * Handle keyboard navigation in filter mentions popup + * @returns {boolean} true if the key was handled + */ +function handleFilterMentionKeydown(e) { + const popup = document.getElementById('filterMentionsPopup'); + const items = popup.querySelectorAll('.mention-item'); + if (items.length === 0) return false; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + filterMentionSelectedIndex = Math.min(filterMentionSelectedIndex + 1, items.length - 1); + updateFilterMentionHighlight(items); + return true; + case 'ArrowUp': + e.preventDefault(); + filterMentionSelectedIndex = Math.max(filterMentionSelectedIndex - 1, 0); + updateFilterMentionHighlight(items); + return true; + case 'Enter': + case 'Tab': + if (items.length > 0 && filterMentionSelectedIndex < items.length) { + e.preventDefault(); + const selected = items[filterMentionSelectedIndex]; + if (selected && selected.dataset.contact) { + selectFilterMentionContact(selected.dataset.contact); + } + return true; + } + break; + } + return false; +} + +/** + * Show filter mentions popup with filtered contacts + */ +function showFilterMentionsPopup(query) { + const popup = document.getElementById('filterMentionsPopup'); + const list = document.getElementById('filterMentionsList'); + + // Ensure contacts are loaded + loadContactsForMentions(); + + const filtered = filterContacts(query); + + if (filtered.length === 0) { + list.innerHTML = '
No contacts found
'; + popup.classList.remove('hidden'); + return; + } + + if (filterMentionSelectedIndex >= filtered.length) { + filterMentionSelectedIndex = 0; + } + + list.innerHTML = filtered.map((contact, index) => { + const highlighted = index === filterMentionSelectedIndex ? 'highlighted' : ''; + const escapedName = escapeHtml(contact); + return `
+ ${escapedName} +
`; + }).join(''); + + list.querySelectorAll('.mention-item').forEach(item => { + item.addEventListener('click', function() { + selectFilterMentionContact(this.dataset.contact); + }); + }); + + popup.classList.remove('hidden'); +} + +/** + * Hide filter mentions popup + */ +function hideFilterMentionsPopup() { + const popup = document.getElementById('filterMentionsPopup'); + if (popup) popup.classList.add('hidden'); + filterMentionActive = false; + filterMentionStartPos = -1; + filterMentionSelectedIndex = 0; +} + +/** + * Update highlight in filter mentions popup + */ +function updateFilterMentionHighlight(items) { + items.forEach((item, index) => { + if (index === filterMentionSelectedIndex) { + item.classList.add('highlighted'); + item.scrollIntoView({ block: 'nearest' }); + } else { + item.classList.remove('highlighted'); + } + }); +} + +/** + * Select a contact from filter mentions and insert plain name + */ +function selectFilterMentionContact(contactName) { + const input = document.getElementById('filterInput'); + const text = input.value; + + // Replace from @ position to cursor with plain contact name + const beforeMention = text.substring(0, filterMentionStartPos); + const afterCursor = text.substring(input.selectionStart); + + input.value = beforeMention + contactName + afterCursor; + + // Set cursor position after the name + const newCursorPos = filterMentionStartPos + contactName.length; + input.setSelectionRange(newCursorPos, newCursorPos); + + hideFilterMentionsPopup(); + input.focus(); + + // Trigger filter with the new value + applyFilter(input.value); +} + /** * Clear filter state when messages are reloaded * Called from displayMessages() diff --git a/app/templates/index.html b/app/templates/index.html index 23b9165..5b27bd1 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -76,7 +76,13 @@
- +
+ + + +
-
+
+