Skip to main content

max / goingson

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