/**
* GoingsOn - Address Highlight Module
*
* Adds inline color highlighting to email address inputs (To/CC/BCC).
* Uses a mirror overlay div behind a transparent-text input to show
* per-address status colors without chips or tokens.
*
* Also provides ghost text autocomplete: the top matching contact email
* appears as grey text after the cursor. Press Tab to accept.
*
* Status colors:
* red = malformed (fails RFC validation)
* default = valid but unrecognized
* blue = in contacts
* green = verified (received email from that address)
*/
(function() {
'use strict';
// Session-scoped cache: email (lowercase) -> status string
const statusCache = new Map();
let debounceTimer = null;
/**
* Attach address highlighting and ghost text to an email input element.
* @param {HTMLInputElement} input - The text input for email addresses
* @param {object} [opts] - Options
* @param {Function} [opts.invoke] - Tauri invoke function (standalone compose.html)
* @param {Function} [opts.contacts] - Returns [{name, email}] array for ghost text
*/
function attach(input, opts) {
if (!input || input._addressHighlight) return;
// Create mirror div
const mirror = document.createElement('div');
mirror.className = 'address-highlight-mirror';
mirror.setAttribute('aria-hidden', 'true');
// Insert mirror before the input in its container
const wrapper = input.closest('.autocomplete-wrapper') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.insertBefore(mirror, input);
// Copy font metrics from input to mirror
const cs = getComputedStyle(input);
mirror.style.fontFamily = cs.fontFamily;
mirror.style.fontSize = cs.fontSize;
mirror.style.fontWeight = cs.fontWeight;
mirror.style.letterSpacing = cs.letterSpacing;
mirror.style.paddingTop = cs.paddingTop;
mirror.style.paddingRight = cs.paddingRight;
mirror.style.paddingBottom = cs.paddingBottom;
mirror.style.paddingLeft = cs.paddingLeft;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.color = 'var(--text-primary)';
// Make input text invisible but keep caret and selection visible
input.style.webkitTextFillColor = 'transparent';
input.style.caretColor = 'var(--text-primary)';
input.style.background = 'transparent';
input.style.position = 'relative';
input.style.zIndex = '1';
// Resolve the validation API call
const validate = opts?.invoke
? (addresses) => opts.invoke('validate_email_addresses', { addresses })
: (typeof GoingsOn !== 'undefined' && GoingsOn.api?.contacts?.validateAddresses)
? (addresses) => GoingsOn.api.contacts.validateAddresses(addresses)
: null;
// Resolve contacts data source for ghost text
const getContacts = opts?.contacts || null;
// Current ghost suggestion (the completion suffix, not the full email)
let ghostSuffix = '';
/**
* Get the current token being typed (text after the last comma, up to cursor).
*/
function getCurrentToken() {
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(),
rawToken: before.slice(lastComma + 1),
cursor,
lastComma,
atEnd: cursor === val.length || val.slice(cursor).trim() === '',
};
}
/**
* Find the best ghost text completion for the current token.
* Prefers explicit contacts over implicit (10x priority).
*/
function findGhostCompletion(token) {
if (!token || token.length < 2) return '';
const contacts = getContacts?.();
if (!contacts || contacts.length === 0) return '';
const q = token.toLowerCase();
// First pass: explicit contacts only
for (const c of contacts) {
if (c.isImplicit) continue;
const email = c.email.toLowerCase();
if (email.startsWith(q)) {
return c.email.slice(token.length);
}
}
// Second pass: implicit contacts as fallback
for (const c of contacts) {
if (!c.isImplicit) continue;
const email = c.email.toLowerCase();
if (email.startsWith(q)) {
return c.email.slice(token.length);
}
}
return '';
}
function syncMirror() {
const value = input.value;
ghostSuffix = '';
if (!value) {
mirror.innerHTML = '';
return;
}
const parts = value.split(',');
const spans = parts.map((part, i) => {
const trimmed = part.trim();
const status = trimmed ? statusCache.get(trimmed.toLowerCase()) : null;
const cls = status === 'malformed' ? ' class="addr-malformed"'
: status === 'contact' ? ' class="addr-contact"'
: status === 'verified' ? ' class="addr-verified"'
: '';
// Preserve exact whitespace from original for position matching
const escaped = escapeHtml(part);
const html = trimmed && cls
? escaped.replace(escapeHtml(trimmed), `${escapeHtml(trimmed)}`)
: escaped;
return html + (i < parts.length - 1 ? ',' : '');
});
let mirrorHtml = spans.join('');
// Ghost text: show completion for current token if cursor is at end
if (getContacts) {
const { token, atEnd } = getCurrentToken();
if (atEnd && token) {
ghostSuffix = findGhostCompletion(token);
if (ghostSuffix) {
mirrorHtml += `${escapeHtml(ghostSuffix)}`;
}
}
}
mirror.innerHTML = mirrorHtml;
mirror.scrollLeft = input.scrollLeft;
}
function scheduleValidation() {
if (!validate) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const parts = input.value.split(',');
const uncached = [];
for (const part of parts) {
const trimmed = part.trim();
if (trimmed && !statusCache.has(trimmed.toLowerCase())) {
uncached.push(trimmed);
}
}
if (uncached.length === 0) return;
try {
const results = await validate(uncached);
for (const r of results) {
statusCache.set(r.email.toLowerCase(), r.status);
}
syncMirror();
} catch (_) {
// Validation failed silently — addresses stay default color
}
}, 250);
}
function onInput() {
syncMirror();
scheduleValidation();
}
function onScroll() {
mirror.scrollLeft = input.scrollLeft;
}
function onKeydown(e) {
// Tab commits ghost text suggestion
if (e.key === 'Tab' && ghostSuffix) {
// Only commit if no dropdown item is actively selected
// (the autocomplete dropdown handles its own Tab)
const dropdown = wrapper.querySelector('.autocomplete-dropdown .autocomplete-item.active');
if (dropdown) return; // let autocomplete handle it
e.preventDefault();
const { cursor } = getCurrentToken();
const before = input.value.slice(0, cursor);
const after = input.value.slice(cursor);
input.value = before + ghostSuffix + ', ' + after.trimStart();
const newCursor = (before + ghostSuffix + ', ').length;
input.setSelectionRange(newCursor, newCursor);
ghostSuffix = '';
onInput(); // re-sync mirror + validate the completed address
}
}
input.addEventListener('input', onInput);
input.addEventListener('scroll', onScroll);
input.addEventListener('keydown', onKeydown);
// Store for detach and manual triggering
input._addressHighlight = { mirror, syncMirror, scheduleValidation, onInput, onScroll, onKeydown };
// Initial sync if input already has a value (e.g., reply pre-fill)
if (input.value) {
syncMirror();
scheduleValidation();
}
}
/**
* Remove address highlighting from an input.
* @param {HTMLInputElement} input
*/
function detach(input) {
if (!input?._addressHighlight) return;
const { mirror, onInput, onScroll, onKeydown } = input._addressHighlight;
mirror.remove();
input.removeEventListener('input', onInput);
input.removeEventListener('scroll', onScroll);
input.removeEventListener('keydown', onKeydown);
input.style.webkitTextFillColor = '';
input.style.caretColor = '';
input.style.background = '';
input.style.position = '';
input.style.zIndex = '';
delete input._addressHighlight;
}
/** Clear the validation cache (e.g., after adding a new contact). */
function clearCache() {
statusCache.clear();
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
// Export to GoingsOn namespace or window (for standalone compose.html)
if (typeof GoingsOn !== 'undefined') {
GoingsOn.addressHighlight = { attach, detach, clearCache };
} else {
window.attachAddressHighlight = attach;
window.detachAddressHighlight = detach;
window.clearAddressHighlightCache = clearCache;
}
})();