diff --git a/app/static/css/style.css b/app/static/css/style.css index 91ed967..8c76215 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -902,3 +902,88 @@ main { color: #212529; background-color: #FF9800; } + +/* ============================================================================= + Mentions Autocomplete Popup + ============================================================================= */ + +.mentions-popup { + position: absolute; + bottom: 100%; + left: 0; + z-index: 1001; + margin-bottom: 0.5rem; + max-height: 200px; + width: 280px; + overflow-y: auto; + background-color: white; + border: 1px solid #dee2e6; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.mentions-popup.hidden { + display: none; +} + +.mentions-list { + list-style: none; + margin: 0; + padding: 0; +} + +.mention-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + font-size: 0.9rem; + transition: background-color 0.1s ease; +} + +.mention-item:last-child { + border-bottom: none; +} + +.mention-item:hover, +.mention-item.highlighted { + background-color: #e7f1ff; +} + +.mention-item-name { + font-weight: 500; +} + +.mentions-empty { + padding: 0.75rem; + text-align: center; + color: #6c757d; + font-size: 0.85rem; +} + +/* Mobile responsive */ +@media (max-width: 576px) { + .mentions-popup { + width: 100%; + max-width: none; + left: 0; + right: 0; + } +} + +/* Mentions popup scrollbar */ +.mentions-popup::-webkit-scrollbar { + width: 6px; +} + +.mentions-popup::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.mentions-popup::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +.mentions-popup::-webkit-scrollbar-thumb:hover { + background: #aaa; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 4aeecd7..a69e673 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -22,6 +22,13 @@ let markersGroup = null; let contactsGeoCache = {}; // { 'contactName': { lat, lon }, ... } let allContactsWithGps = []; // Cached contacts for map filtering +// Mentions autocomplete state +let mentionsCache = []; // Cached contact list +let mentionsCacheTimestamp = 0; // Cache timestamp +let mentionStartPos = -1; // Position of @ in textarea +let mentionSelectedIndex = 0; // Currently highlighted item +let isMentionMode = false; // Is mention dropdown active + // Contact type colors for map markers const CONTACT_TYPE_COLORS = { 1: '#2196F3', // CLI - blue @@ -389,6 +396,9 @@ function setupEventListeners() { updateCharCounter(); }); + // Setup mentions autocomplete + setupMentionsAutocomplete(); + // Manual refresh button document.getElementById('refreshBtn').addEventListener('click', async function() { await loadMessages(); @@ -2422,3 +2432,266 @@ function loadPendingTypeFilter() { return [1]; } +// ============================================================================= +// Mentions Autocomplete Functions +// ============================================================================= + +/** + * Setup mentions autocomplete functionality + */ +function setupMentionsAutocomplete() { + const input = document.getElementById('messageInput'); + const popup = document.getElementById('mentionsPopup'); + + if (!input || !popup) { + console.warn('[mentions] Required elements not found'); + return; + } + + // Track @ trigger on input + input.addEventListener('input', handleMentionInput); + + // Handle keyboard navigation + input.addEventListener('keydown', handleMentionKeydown); + + // Close popup on blur (with delay to allow click selection) + input.addEventListener('blur', function() { + setTimeout(hideMentionsPopup, 200); + }); + + // Preload contacts on focus + input.addEventListener('focus', function() { + loadContactsForMentions(); + }); + + // Click outside to close + document.addEventListener('click', function(e) { + if (!popup.contains(e.target) && e.target !== input) { + hideMentionsPopup(); + } + }); + + console.log('[mentions] Autocomplete initialized'); +} + +/** + * Handle input event for mention detection + */ +function handleMentionInput(e) { + const input = e.target; + const cursorPos = input.selectionStart; + const text = input.value; + + // Find @ character before cursor + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtPos = textBeforeCursor.lastIndexOf('@'); + + // Check if we should be in mention mode + if (lastAtPos >= 0) { + // Check if there's a space or newline between @ and cursor (mention ended) + const textAfterAt = textBeforeCursor.substring(lastAtPos + 1); + + // Allow alphanumeric, underscore, dash, emoji, and other non-whitespace chars in username + // Space or newline ends the mention + if (!/[\s\n]/.test(textAfterAt)) { + // We're in mention mode + mentionStartPos = lastAtPos; + isMentionMode = true; + const query = textAfterAt; + showMentionsPopup(query); + return; + } + } + + // Not in mention mode + if (isMentionMode) { + hideMentionsPopup(); + } +} + +/** + * Handle keyboard navigation in mentions popup + */ +function handleMentionKeydown(e) { + if (!isMentionMode) return; + + const popup = document.getElementById('mentionsPopup'); + const items = popup.querySelectorAll('.mention-item'); + + if (items.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + mentionSelectedIndex = Math.min(mentionSelectedIndex + 1, items.length - 1); + updateMentionHighlight(items); + break; + + case 'ArrowUp': + e.preventDefault(); + mentionSelectedIndex = Math.max(mentionSelectedIndex - 1, 0); + updateMentionHighlight(items); + break; + + case 'Enter': + case 'Tab': + if (items.length > 0 && mentionSelectedIndex < items.length) { + e.preventDefault(); + const selected = items[mentionSelectedIndex]; + if (selected && selected.dataset.contact) { + selectMentionContact(selected.dataset.contact); + } + } + break; + + case 'Escape': + e.preventDefault(); + hideMentionsPopup(); + break; + } +} + +/** + * Show mentions popup with filtered contacts + */ +function showMentionsPopup(query) { + const popup = document.getElementById('mentionsPopup'); + const list = document.getElementById('mentionsList'); + + // Filter contacts + const filtered = filterContacts(query); + + if (filtered.length === 0) { + list.innerHTML = '