From 3e1537fddefb28bd967f8706f985de6948652dd9 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 26 Jan 2026 08:14:25 +0100 Subject: [PATCH] feat: Add @mentions autocomplete in channel chat When user types @ in the message input, a dropdown appears with contacts list. The list filters as user types (matches any part of name, not just prefix). User can navigate with arrow keys, select with Enter/Tab/click, or dismiss with Escape. - Add mentions popup HTML to index.html - Add mentions CSS styling (responsive, scrollable) - Add JavaScript logic: detection, filtering, keyboard nav, insertion - Contacts cached for 60s, loaded on input focus - Closes emoji picker when mentions opens (avoid overlap) - Inserts selected contact as @[username] format Co-Authored-By: Claude Opus 4.5 --- app/static/css/style.css | 85 ++++++++++++ app/static/js/app.js | 273 +++++++++++++++++++++++++++++++++++++++ app/templates/index.html | 4 + 3 files changed, 362 insertions(+) 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 = '
No contacts found
'; + popup.classList.remove('hidden'); + return; + } + + // Reset selection index if out of bounds + if (mentionSelectedIndex >= filtered.length) { + mentionSelectedIndex = 0; + } + + // Build list HTML + list.innerHTML = filtered.map((contact, index) => { + const highlighted = index === mentionSelectedIndex ? 'highlighted' : ''; + const escapedName = escapeHtml(contact); + return `
+ ${escapedName} +
`; + }).join(''); + + // Add click handlers + list.querySelectorAll('.mention-item').forEach(item => { + item.addEventListener('click', function() { + selectMentionContact(this.dataset.contact); + }); + }); + + // Close emoji picker if open (avoid overlapping popups) + const emojiPopup = document.getElementById('emojiPickerPopup'); + if (emojiPopup && !emojiPopup.classList.contains('hidden')) { + emojiPopup.classList.add('hidden'); + } + + popup.classList.remove('hidden'); +} + +/** + * Hide mentions popup and reset state + */ +function hideMentionsPopup() { + const popup = document.getElementById('mentionsPopup'); + if (popup) { + popup.classList.add('hidden'); + } + isMentionMode = false; + mentionStartPos = -1; + mentionSelectedIndex = 0; +} + +/** + * Filter contacts by query (matches any part of name) + */ +function filterContacts(query) { + if (!mentionsCache || mentionsCache.length === 0) { + return []; + } + + const lowerQuery = query.toLowerCase(); + + // Filter by any part of the name (not just prefix) + return mentionsCache.filter(contact => + contact.toLowerCase().includes(lowerQuery) + ).slice(0, 10); // Limit to 10 results for performance +} + +/** + * Update highlight on mention items + */ +function updateMentionHighlight(items) { + items.forEach((item, index) => { + if (index === mentionSelectedIndex) { + item.classList.add('highlighted'); + // Scroll item into view if needed + item.scrollIntoView({ block: 'nearest' }); + } else { + item.classList.remove('highlighted'); + } + }); +} + +/** + * Select a contact and insert mention into textarea + */ +function selectMentionContact(contactName) { + const input = document.getElementById('messageInput'); + const text = input.value; + + // Replace from @ position to cursor with @[contactName] + const beforeMention = text.substring(0, mentionStartPos); + const afterCursor = text.substring(input.selectionStart); + + const mention = `@[${contactName}] `; + input.value = beforeMention + mention + afterCursor; + + // Set cursor position after the mention + const newCursorPos = mentionStartPos + mention.length; + input.setSelectionRange(newCursorPos, newCursorPos); + + // Update character counter + updateCharCounter(); + + // Hide popup and reset state + hideMentionsPopup(); + + // Keep focus on input + input.focus(); +} + +/** + * Load contacts for mentions autocomplete (with caching) + */ +async function loadContactsForMentions() { + const CACHE_TTL = 60000; // 60 seconds + const now = Date.now(); + + // Return cached if still valid + if (mentionsCache.length > 0 && (now - mentionsCacheTimestamp) < CACHE_TTL) { + return; + } + + try { + const response = await fetch('/api/contacts'); + const data = await response.json(); + + if (data.success && data.contacts) { + mentionsCache = data.contacts; + mentionsCacheTimestamp = now; + console.log(`[mentions] Cached ${mentionsCache.length} contacts`); + } + } catch (error) { + console.error('[mentions] Error loading contacts:', error); + } +} + diff --git a/app/templates/index.html b/app/templates/index.html index a940a88..723407c 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -110,6 +110,10 @@ + +
0 / 140