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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-26 08:14:25 +01:00
parent 98e4482054
commit 3e1537fdde
3 changed files with 362 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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 = '<div class="mentions-empty">No contacts found</div>';
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 `<div class="mention-item ${highlighted}" data-contact="${escapedName}" data-index="${index}">
<span class="mention-item-name">${escapedName}</span>
</div>`;
}).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);
}
}

View File

@@ -110,6 +110,10 @@
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
</div>
</div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 140</small>