Skip to main content

max / goingson

10.4 KB · 279 lines History Blame Raw
1 /**
2 * GoingsOn - Address Highlight Module
3 *
4 * Adds inline color highlighting to email address inputs (To/CC/BCC).
5 * Uses a mirror overlay div behind a transparent-text input to show
6 * per-address status colors without chips or tokens.
7 *
8 * Also provides ghost text autocomplete: the top matching contact email
9 * appears as grey text after the cursor. Press Tab to accept.
10 *
11 * Status colors:
12 * red = malformed (fails RFC validation)
13 * default = valid but unrecognized
14 * blue = in contacts
15 * green = verified (received email from that address)
16 */
17
18 (function() {
19 'use strict';
20
21 // Session-scoped cache: email (lowercase) -> status string
22 const statusCache = new Map();
23
24 /**
25 * Attach address highlighting and ghost text to an email input element.
26 * @param {HTMLInputElement} input - The text input for email addresses
27 * @param {object} [opts] - Options
28 * @param {Function} [opts.invoke] - Tauri invoke function (standalone compose.html)
29 * @param {Function} [opts.contacts] - Returns [{name, email}] array for ghost text
30 */
31 function attach(input, opts) {
32 if (!input || input._addressHighlight) return;
33
34 // Per-input debounce timer (each To/CC/BCC field validates independently).
35 let debounceTimer = null;
36
37 // Create mirror div
38 const mirror = document.createElement('div');
39 mirror.className = 'address-highlight-mirror';
40 mirror.setAttribute('aria-hidden', 'true');
41
42 // Insert mirror before the input in its container
43 const wrapper = input.closest('.autocomplete-wrapper') || input.parentElement;
44 wrapper.style.position = 'relative';
45 wrapper.insertBefore(mirror, input);
46
47 // Copy font metrics from input to mirror
48 const cs = getComputedStyle(input);
49 mirror.style.fontFamily = cs.fontFamily;
50 mirror.style.fontSize = cs.fontSize;
51 mirror.style.fontWeight = cs.fontWeight;
52 mirror.style.letterSpacing = cs.letterSpacing;
53 mirror.style.paddingTop = cs.paddingTop;
54 mirror.style.paddingRight = cs.paddingRight;
55 mirror.style.paddingBottom = cs.paddingBottom;
56 mirror.style.paddingLeft = cs.paddingLeft;
57 mirror.style.lineHeight = cs.lineHeight;
58 mirror.style.color = 'var(--content)';
59
60 // Make input text invisible but keep caret and selection visible
61 input.style.webkitTextFillColor = 'transparent';
62 input.style.caretColor = 'var(--content)';
63 input.style.background = 'transparent';
64 input.style.position = 'relative';
65 input.style.zIndex = '1';
66
67 // Resolve the validation API call
68 const validate = opts?.invoke
69 ? (addresses) => opts.invoke('validate_email_addresses', { addresses })
70 : (typeof GoingsOn !== 'undefined' && GoingsOn.api?.contacts?.validateAddresses)
71 ? (addresses) => GoingsOn.api.contacts.validateAddresses(addresses)
72 : null;
73
74 // Resolve contacts data source for ghost text
75 const getContacts = opts?.contacts || null;
76
77 // Current ghost suggestion (the completion suffix, not the full email)
78 let ghostSuffix = '';
79
80 /**
81 * Get the current token being typed (text after the last comma, up to cursor).
82 */
83 function getCurrentToken() {
84 const val = input.value;
85 const cursor = input.selectionStart ?? val.length;
86 const before = val.slice(0, cursor);
87 const lastComma = before.lastIndexOf(',');
88 return {
89 token: before.slice(lastComma + 1).trim(),
90 rawToken: before.slice(lastComma + 1),
91 cursor,
92 lastComma,
93 atEnd: cursor === val.length || val.slice(cursor).trim() === '',
94 };
95 }
96
97 /**
98 * Find the best ghost text completion for the current token.
99 * Prefers explicit contacts over implicit (10x priority).
100 */
101 function findGhostCompletion(token) {
102 if (!token || token.length < 2) return '';
103 const contacts = getContacts?.();
104 if (!contacts || contacts.length === 0) return '';
105
106 const q = token.toLowerCase();
107
108 // First pass: explicit contacts only
109 for (const c of contacts) {
110 if (c.isImplicit) continue;
111 const email = c.email.toLowerCase();
112 if (email.startsWith(q)) {
113 return c.email.slice(token.length);
114 }
115 }
116
117 // Second pass: implicit contacts as fallback
118 for (const c of contacts) {
119 if (!c.isImplicit) continue;
120 const email = c.email.toLowerCase();
121 if (email.startsWith(q)) {
122 return c.email.slice(token.length);
123 }
124 }
125
126 return '';
127 }
128
129 function syncMirror() {
130 const value = input.value;
131 ghostSuffix = '';
132
133 if (!value) {
134 mirror.innerHTML = '';
135 return;
136 }
137
138 const parts = value.split(',');
139 const spans = parts.map((part, i) => {
140 const trimmed = part.trim();
141 const status = trimmed ? statusCache.get(trimmed.toLowerCase()) : null;
142 const cls = status === 'malformed' ? ' class="addr-malformed"'
143 : status === 'contact' ? ' class="addr-contact"'
144 : status === 'verified' ? ' class="addr-verified"'
145 : '';
146
147 // Preserve exact whitespace from original for position matching
148 const escaped = escapeHtml(part);
149 const html = trimmed && cls
150 ? escaped.replace(escapeHtml(trimmed), `<span${cls}>${escapeHtml(trimmed)}</span>`)
151 : escaped;
152
153 return html + (i < parts.length - 1 ? ',' : '');
154 });
155
156 let mirrorHtml = spans.join('');
157
158 // Ghost text: show completion for current token if cursor is at end
159 if (getContacts) {
160 const { token, atEnd } = getCurrentToken();
161 if (atEnd && token) {
162 ghostSuffix = findGhostCompletion(token);
163 if (ghostSuffix) {
164 mirrorHtml += `<span class="addr-ghost">${escapeHtml(ghostSuffix)}</span>`;
165 }
166 }
167 }
168
169 mirror.innerHTML = mirrorHtml;
170 mirror.scrollLeft = input.scrollLeft;
171 }
172
173 function scheduleValidation() {
174 if (!validate) return;
175
176 clearTimeout(debounceTimer);
177 debounceTimer = setTimeout(async () => {
178 const parts = input.value.split(',');
179 const uncached = [];
180
181 for (const part of parts) {
182 const trimmed = part.trim();
183 if (trimmed && !statusCache.has(trimmed.toLowerCase())) {
184 uncached.push(trimmed);
185 }
186 }
187
188 if (uncached.length === 0) return;
189
190 try {
191 const results = await validate(uncached);
192 for (const r of results) {
193 statusCache.set(r.email.toLowerCase(), r.status);
194 }
195 syncMirror();
196 } catch (_) {
197 // Validation failed silently — addresses stay default color
198 }
199 }, 250);
200 }
201
202 function onInput() {
203 syncMirror();
204 scheduleValidation();
205 }
206
207 function onScroll() {
208 mirror.scrollLeft = input.scrollLeft;
209 }
210
211 function onKeydown(e) {
212 // Tab commits ghost text suggestion
213 if (e.key === 'Tab' && ghostSuffix) {
214 // Only commit if no dropdown item is actively selected
215 // (the autocomplete dropdown handles its own Tab)
216 const dropdown = wrapper.querySelector('.autocomplete-dropdown .autocomplete-item.active');
217 if (dropdown) return; // let autocomplete handle it
218
219 e.preventDefault();
220 const { cursor } = getCurrentToken();
221 const before = input.value.slice(0, cursor);
222 const after = input.value.slice(cursor);
223 input.value = before + ghostSuffix + ', ' + after.trimStart();
224 const newCursor = (before + ghostSuffix + ', ').length;
225 input.setSelectionRange(newCursor, newCursor);
226 ghostSuffix = '';
227 onInput(); // re-sync mirror + validate the completed address
228 }
229 }
230
231 input.addEventListener('input', onInput);
232 input.addEventListener('scroll', onScroll);
233 input.addEventListener('keydown', onKeydown);
234
235 // Store for detach and manual triggering
236 input._addressHighlight = { mirror, syncMirror, scheduleValidation, onInput, onScroll, onKeydown };
237
238 // Initial sync if input already has a value (e.g., reply pre-fill)
239 if (input.value) {
240 syncMirror();
241 scheduleValidation();
242 }
243 }
244
245 /**
246 * Remove address highlighting from an input.
247 * @param {HTMLInputElement} input
248 */
249 function detach(input) {
250 if (!input?._addressHighlight) return;
251 const { mirror, onInput, onScroll, onKeydown } = input._addressHighlight;
252 mirror.remove();
253 input.removeEventListener('input', onInput);
254 input.removeEventListener('scroll', onScroll);
255 input.removeEventListener('keydown', onKeydown);
256 input.style.webkitTextFillColor = '';
257 input.style.caretColor = '';
258 input.style.background = '';
259 input.style.position = '';
260 input.style.zIndex = '';
261 delete input._addressHighlight;
262 }
263
264 /** Clear the validation cache (e.g., after adding a new contact). */
265 function clearCache() {
266 statusCache.clear();
267 }
268
269 // Text-context HTML escaping via the shared single-source primitive
270 // (js/escape.js), loaded before this file in both compose.html and index.html.
271 const escapeHtml = GoingsOn.escape.escapeHtml;
272
273 // Export to the GoingsOn namespace. escape.js (loaded before this file in
274 // both compose.html and index.html) guarantees GoingsOn exists, so no
275 // window.* fallback is needed.
276 GoingsOn.addressHighlight = { attach, detach, clearCache };
277
278 })();
279