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