/** * GoingsOn - Email Address Autocomplete * Typeahead for To/CC/BCC fields using contacts database. */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; let contactEmails = null; // [{name, email}], lazy-loaded /** * Load all contact email addresses for autocomplete. * Cached after first load; call refresh() to reload. */ async function ensureLoaded() { if (contactEmails !== null) return; try { const contacts = await GoingsOn.api.contacts.listFiltered(null, null, true); contactEmails = []; for (const c of contacts) { const name = c.displayName || c.display_name || ''; const isImplicit = c.isImplicit || false; if (c.emails && c.emails.length > 0) { for (const e of c.emails) { contactEmails.push({ name, email: e.address, isImplicit }); } } } } catch (_) { contactEmails = []; } } function refresh() { contactEmails = null; } /** * Get the current token being typed (after the last comma). */ function getLastToken(input) { const val = input.value; const cursor = input.selectionStart || val.length; const before = val.slice(0, cursor); const lastComma = before.lastIndexOf(','); return { token: before.slice(lastComma + 1).trim(), lastComma }; } function filterContacts(token) { if (!token || token.length < 1 || !contactEmails) return []; const q = token.toLowerCase(); return contactEmails .filter(c => c.email.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)) .sort((a, b) => { // Explicit contacts first, then implicit if (a.isImplicit !== b.isImplicit) return a.isImplicit ? 1 : -1; // Within tier, prefer prefix match on email const aPrefix = a.email.toLowerCase().startsWith(q) ? 0 : 1; const bPrefix = b.email.toLowerCase().startsWith(q) ? 0 : 1; return aPrefix - bPrefix; }) .slice(0, 8); } /** * Attach autocomplete behavior to an email address input. * Supports comma-separated addresses — autocompletes the current token. * @param {HTMLInputElement} input - The text input element */ function attach(input) { let dropdown = null; let activeIndex = -1; let matches = []; function show(filteredMatches) { hide(); if (filteredMatches.length === 0) return; matches = filteredMatches; activeIndex = -1; dropdown = document.createElement('div'); dropdown.className = 'autocomplete-dropdown'; filteredMatches.forEach((m, i) => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.innerHTML = `${esc(m.name)} ${esc(m.email)}`; item.addEventListener('mousedown', (e) => { e.preventDefault(); select(m.email); }); dropdown.appendChild(item); }); // Position relative to input's parent const wrapper = input.parentElement; wrapper.style.position = 'relative'; dropdown.style.position = 'absolute'; dropdown.style.top = input.offsetTop + input.offsetHeight + 'px'; dropdown.style.left = '0'; dropdown.style.right = '0'; wrapper.appendChild(dropdown); } function hide() { if (dropdown) { dropdown.remove(); dropdown = null; activeIndex = -1; matches = []; } } function select(email) { const val = input.value; const cursor = input.selectionStart || val.length; const before = val.slice(0, cursor); const after = val.slice(cursor); const lastComma = before.lastIndexOf(','); const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : ''; input.value = prefix + email + ', ' + after.trimStart(); const newCursor = (prefix + email + ', ').length; input.setSelectionRange(newCursor, newCursor); input.focus(); hide(); } input.addEventListener('input', async () => { await ensureLoaded(); const { token } = getLastToken(input); show(filterContacts(token)); }); input.addEventListener('blur', () => { setTimeout(hide, 150); }); input.addEventListener('keydown', (e) => { if (!dropdown) return; const items = dropdown.querySelectorAll('.autocomplete-item'); if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, items.length - 1); items.forEach((el, i) => el.classList.toggle('active', i === activeIndex)); } else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); items.forEach((el, i) => el.classList.toggle('active', i === activeIndex)); } else if (e.key === 'Enter' || e.key === 'Tab') { if (activeIndex >= 0 && activeIndex < matches.length) { e.preventDefault(); select(matches[activeIndex].email); } } else if (e.key === 'Escape') { hide(); } }); } GoingsOn.autocomplete = { attach, refresh, getContacts: () => contactEmails || [] }; })();