/**
* 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 || [] };
})();